diff --git a/build.gradle b/build.gradle index e74c7a90..0d4e3355 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,13 @@ dependencies { // HTML -> PDF (OpenHTMLToPDF) implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' implementation 'com.openhtmltopdf:openhtmltopdf-slf4j:1.0.10' + + // MongoDB + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + // STOMP + implementation 'org.springframework.boot:spring-boot-starter-websocket' +// implementation 'org.springframework.boot:spring-boot-starter-messaging' } tasks.named('test') { diff --git a/src/main/java/konkuk/chacall/domain/chat/application/ChatService.java b/src/main/java/konkuk/chacall/domain/chat/application/ChatService.java index 27e0dd0d..4c4ece38 100644 --- a/src/main/java/konkuk/chacall/domain/chat/application/ChatService.java +++ b/src/main/java/konkuk/chacall/domain/chat/application/ChatService.java @@ -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 getChatMessages(Long memberId, Long roomId, int page, int size) { + User user = memberValidator.validateAndGetMember(memberId); + + return chatMessageService.getChatMessages(roomId, user, page, size); + } + + public CursorPagingResponse 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); + } } diff --git a/src/main/java/konkuk/chacall/domain/chat/application/message/ChatMessageService.java b/src/main/java/konkuk/chacall/domain/chat/application/message/ChatMessageService.java new file mode 100644 index 00000000..34881075 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/application/message/ChatMessageService.java @@ -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 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); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/application/room/ChatRoomService.java b/src/main/java/konkuk/chacall/domain/chat/application/room/ChatRoomService.java new file mode 100644 index 00000000..485fca0e --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/application/room/ChatRoomService.java @@ -0,0 +1,133 @@ +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.reservation.domain.repository.dto.ReservationConfirmedProjection; +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.Collections; +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 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 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 roomIds = metaList.stream() + .map(ChatRoomMetaDataProjection::getRoomId) + .toList(); + + List chatRooms = chatRoomRepository.findByChatRoomIdIn(roomIds); + Map chatRoomMap = chatRooms.stream() + .collect(Collectors.toMap(ChatRoom::getChatRoomId, Function.identity())); + + // 3. 예약 확정 여부 조회 + Map reservationConfirmedMap; + if (roomIds.isEmpty()) { + reservationConfirmedMap = Collections.emptyMap(); + } else { + List confirmedList = reservationRepository.findReservationConfirmedByChatRoomIds(roomIds); + + reservationConfirmedMap = confirmedList.stream() + .collect(Collectors.toMap( + ReservationConfirmedProjection::getRoomId, + ReservationConfirmedProjection::getConfirmed + )); + } + + // 4. 메타데이터 + RDB 정보 조합해서 ChatRoomResponse 생성 + List responses = metaList.stream() + .map(meta -> ChatRoomResponse.from(chatRoomMap.get(meta.getRoomId()), meta, isOwner, + reservationConfirmedMap.getOrDefault(meta.getRoomId(), false))) + .toList(); + + // 5. CursorPagingResponse 생성 + Long lastCursor = responses.isEmpty() ? null : metaList.get(metaList.size() - 1).getSortKey(); + return new CursorPagingResponse<>( + responses, + lastCursor, + hasNext, + null + ); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/ChatMessage.java b/src/main/java/konkuk/chacall/domain/chat/domain/ChatMessage.java index 9267b1bb..6d55d355 100644 --- a/src/main/java/konkuk/chacall/domain/chat/domain/ChatMessage.java +++ b/src/main/java/konkuk/chacall/domain/chat/domain/ChatMessage.java @@ -1,43 +1,45 @@ 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_sender_read_time_idx", + def = "{'roomId': 1, 'senderId': 1, 'read': 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(); + } } diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoom.java b/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoom.java index f306536c..73f96f45 100644 --- a/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoom.java +++ b/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoom.java @@ -1,12 +1,18 @@ package konkuk.chacall.domain.chat.domain; import jakarta.persistence.*; +import konkuk.chacall.domain.foodtruck.domain.model.FoodTruck; +import konkuk.chacall.domain.reservation.domain.model.Reservation; import konkuk.chacall.domain.user.domain.model.User; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; +import konkuk.chacall.global.common.exception.DomainRuleException; +import konkuk.chacall.global.common.exception.code.ErrorCode; +import lombok.*; @Entity @Table(name = "chat_rooms") +@Getter +@Builder +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ChatRoom { @@ -16,11 +22,18 @@ public class ChatRoom { private Long chatRoomId; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false, referencedColumnName = "user_id") + @JoinColumn(name = "member_id", nullable = false) private User member; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "owner_id", nullable = false, referencedColumnName = "user_id") - private User owner; + @JoinColumn(name = "food_truck_id", nullable = false) + private FoodTruck foodTruck; + + public static ChatRoom createChatRoom(User member, FoodTruck foodTruck) { + return ChatRoom.builder() + .member(member) + .foodTruck(foodTruck) + .build(); + } } diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoomMetaData.java b/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoomMetaData.java new file mode 100644 index 00000000..956d0b8f --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/ChatRoomMetaData.java @@ -0,0 +1,79 @@ +package konkuk.chacall.domain.chat.domain; + +import konkuk.chacall.domain.user.domain.model.User; +import konkuk.chacall.global.common.exception.DomainRuleException; +import konkuk.chacall.global.common.exception.code.ErrorCode; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Document(collection = "chat_room_metadata") +@CompoundIndexes({ + // 예약자 기준 목록 조회를 위한 인덱스 + @CompoundIndex( + name = "member_sort_idx", + def = "{ 'memberId': 1, 'sortKey': -1 }" + ), + // 사장 기준 목록 조회을 위한 인덱스 + @CompoundIndex( + name = "owner_sort_idx", + def = "{ 'ownerId': 1, 'sortKey': -1 }" + ) +}) +public class ChatRoomMetaData { + + @Id + private String id; + + // RDB ChatRoom 식별자 + private Long roomId; + + // 참여자 정보 (예약자 / 사장) + private Long memberId; + private Long ownerId; + + // 마지막 메시지 정보 + private String lastMessage; + private LocalDateTime lastMessageSendTime; + + /** + * 정렬 및 커서용 키 + * - lastMessageSendTime 을 epoch milli 로 변환한 값 + */ + private Long sortKey; + + public static ChatRoomMetaData from(ChatRoom chatRoom) { + return ChatRoomMetaData.builder() + .roomId(chatRoom.getChatRoomId()) + .memberId(chatRoom.getMember().getUserId()) + .ownerId(chatRoom.getFoodTruck().getOwner().getUserId()) + .lastMessage(null) + .lastMessageSendTime(null) + .sortKey(Long.MAX_VALUE - chatRoom.getChatRoomId()) // 메시지 없는 방은 가장 뒤로 밀리도록 초기값 + .build(); + } + + public void updateLastMessage(String content, LocalDateTime sendTime) { + this.lastMessage = content; + this.lastMessageSendTime = sendTime; + if (sendTime != null) { + this.sortKey = sendTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + } + + public void validateParticipant(User user) { + if(!this.memberId.equals(user.getUserId()) && + !this.ownerId.equals(user.getUserId())) { + throw new DomainRuleException(ErrorCode.CHAT_ROOM_FORBIDDEN); + } + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatMessageRepository.java b/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatMessageRepository.java new file mode 100644 index 00000000..6139bdad --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatMessageRepository.java @@ -0,0 +1,42 @@ +package konkuk.chacall.domain.chat.domain.repository; + +import konkuk.chacall.domain.chat.domain.ChatMessage; +import konkuk.chacall.domain.chat.domain.repository.infra.ChatMessageCustomRepository; +import konkuk.chacall.domain.chat.domain.repository.dto.ChatRoomMetaDataProjection; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.mongodb.repository.Aggregation; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +public interface ChatMessageRepository extends MongoRepository, ChatMessageCustomRepository { + List findByRoomId(Long chatRoomId, PageRequest pageable); + + /** + * 채팅방 목록에 필요한 집계 데이터 + * - roomId + * - 마지막 메시지 내용 + * - 마지막 메시지 전송 시간 + * - 로그인 유저 기준 안 읽은 메시지 개수 + */ + @Aggregation(pipeline = { + "{ '$match': { 'roomId': { '$in': ?0 } } }", + "{ '$sort': { 'sendTime': -1 } }", + "{ '$group': { " + + " '_id': '$roomId'," + + " 'roomId': { '$first': '$roomId' }," + + " 'lastMessage': { '$first': '$content' }," + + " 'lastMessageSendTime': { '$first': '$sendTime' }," + + " 'unreadCount': { " + + " '$sum': { " + + " '$cond': [" + + " { '$and': [ { '$eq': ['$read', false] }, { '$ne': ['$senderId', ?1] } ] }," + + " 1," + + " 0" + + " ]" + + " }" + + " }" + + "} }" + }) + List aggregateChatRoomSummaries(List roomIds, Long userId); +} diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatRoomMetaDataRepository.java b/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatRoomMetaDataRepository.java new file mode 100644 index 00000000..28319d31 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatRoomMetaDataRepository.java @@ -0,0 +1,54 @@ +package konkuk.chacall.domain.chat.domain.repository; + +import konkuk.chacall.domain.chat.domain.ChatRoomMetaData; +import konkuk.chacall.domain.chat.domain.repository.dto.ChatRoomMetaDataProjection; +import org.springframework.data.mongodb.repository.Aggregation; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; +import java.util.Optional; + +public interface ChatRoomMetaDataRepository extends MongoRepository { + Optional findByRoomId(Long roomId); + + /** + * - 자신의 채팅방만 필터링 + * - 최근 메시지 시간 기준 내림차순 정렬 + * - 커서(sortKey) 기반 페이지네이션 + * - 안읽은 메시지 개수까지 함께 조회 + */ + @Aggregation(pipeline = { + // 1. 내가 속한 채팅방만 필터링 (isOwner 기준) + "{ '$match': { '$expr': { '$and': [" + + " { '$cond': [ ?1, { '$eq': ['$ownerId', ?0] }, { '$eq': ['$memberId', ?0] } ] }," + + " { '$lt': ['$sortKey', ?2] }" + + "] } } }", + + // 2. 안읽은 메시지 개수 계산 (chat_messages 컬렉션 join) + "{ '$lookup': { " + + " 'from': 'chat_messages'," + + " 'let': { 'roomId': '$roomId' }," + + " 'pipeline': [" + + " { '$match': { '$expr': { '$and': [" + + " { '$eq': ['$roomId', '$$roomId'] }," + + " { '$eq': ['$read', false] }," + + " { '$ne': ['$senderId', ?0] }" + + " ] } } }," + + " { '$count': 'unreadCount' }" + + " ]," + + " 'as': 'unreadInfo'" + + "} }", + + // 3. unreadInfo 배열 -> unreadCount 필드로 변환 (없으면 0) + "{ '$addFields': { " + + " 'unreadCount': { '$ifNull': [ { '$arrayElemAt': ['$unreadInfo.unreadCount', 0] }, 0 ] }" + + "} }", + + // 4. sortKey 기준 내림차순 정렬 (가장 최근 대화가 위로) + "{ '$sort': { 'sortKey': -1 } }", + + // 5. 페이지 사이즈 + 1 만큼만 가져와서 hasNext 판단 + "{ '$limit': ?3 }" + }) + List findChatRoomsForUser(Long userId, boolean isOwner, Long cursorSortKey, int limit); +} diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatRoomRepository.java b/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatRoomRepository.java new file mode 100644 index 00000000..9290b9c7 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/repository/ChatRoomRepository.java @@ -0,0 +1,19 @@ +package konkuk.chacall.domain.chat.domain.repository; + +import konkuk.chacall.domain.chat.domain.ChatRoom; +import konkuk.chacall.domain.foodtruck.domain.model.FoodTruck; +import konkuk.chacall.domain.user.domain.model.User; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ChatRoomRepository extends JpaRepository { + + Optional findByMemberAndFoodTruck(User member, FoodTruck foodTruck); + + @EntityGraph(attributePaths = {"member", "foodTruck"}) + List findByChatRoomIdIn(List roomIds); +} diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/repository/dto/ChatRoomMetaDataProjection.java b/src/main/java/konkuk/chacall/domain/chat/domain/repository/dto/ChatRoomMetaDataProjection.java new file mode 100644 index 00000000..e2b08233 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/repository/dto/ChatRoomMetaDataProjection.java @@ -0,0 +1,17 @@ +package konkuk.chacall.domain.chat.domain.repository.dto; + +import java.time.LocalDateTime; + +public interface ChatRoomMetaDataProjection { + Long getRoomId(); + + Long getMemberId(); + Long getOwnerId(); + + String getLastMessage(); + LocalDateTime getLastMessageSendTime(); + + Long getSortKey(); + + Long getUnreadCount(); +} diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/repository/infra/ChatMessageCustomRepository.java b/src/main/java/konkuk/chacall/domain/chat/domain/repository/infra/ChatMessageCustomRepository.java new file mode 100644 index 00000000..e66125ab --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/repository/infra/ChatMessageCustomRepository.java @@ -0,0 +1,5 @@ +package konkuk.chacall.domain.chat.domain.repository.infra; + +public interface ChatMessageCustomRepository { + void markMessagesAsReadByUserInRoom(Long userId, Long roomId); +} diff --git a/src/main/java/konkuk/chacall/domain/chat/domain/repository/infra/ChatMessageCustomRepositoryImpl.java b/src/main/java/konkuk/chacall/domain/chat/domain/repository/infra/ChatMessageCustomRepositoryImpl.java new file mode 100644 index 00000000..03f467f1 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/domain/repository/infra/ChatMessageCustomRepositoryImpl.java @@ -0,0 +1,43 @@ +package konkuk.chacall.domain.chat.domain.repository.infra; + +import konkuk.chacall.domain.chat.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +@RequiredArgsConstructor +public class ChatMessageCustomRepositoryImpl implements ChatMessageCustomRepository { + private final MongoTemplate mongoTemplate; + + @Override + public void markMessagesAsReadByUserInRoom(Long userId, Long roomId) { + // 1) 가장 최근의 읽음 메시지 1개 찾기 + Query lastReadQuery = new Query(); + lastReadQuery.addCriteria(Criteria.where("roomId").is(roomId)); + lastReadQuery.addCriteria(Criteria.where("senderId").ne(userId)); + lastReadQuery.addCriteria(Criteria.where("read").is(true)); + lastReadQuery.with(Sort.by(Sort.Direction.DESC, "sendTime")); + lastReadQuery.limit(1); + + ChatMessage lastReadMessage = + mongoTemplate.findOne(lastReadQuery, ChatMessage.class); + + Update update = new Update().set("read", true); + + Query updateQuery = new Query(); + updateQuery.addCriteria(Criteria.where("roomId").is(roomId)); + updateQuery.addCriteria(Criteria.where("senderId").ne(userId)); + updateQuery.addCriteria(Criteria.where("read").is(false)); + + // 2) 마지막 읽음 메시지가 있다면 sendTime 조건 추가 + if (lastReadMessage != null) { + updateQuery.addCriteria(Criteria.where("sendTime").gt(lastReadMessage.getSendTime())); + } + + // 3) 일괄 업데이트 + mongoTemplate.updateMulti(updateQuery, update, ChatMessage.class); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/ChatController.java b/src/main/java/konkuk/chacall/domain/chat/presentation/ChatController.java index c9b1ccfd..ad0e3158 100644 --- a/src/main/java/konkuk/chacall/domain/chat/presentation/ChatController.java +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/ChatController.java @@ -1,4 +1,32 @@ package konkuk.chacall.domain.chat.presentation; +import io.swagger.v3.oas.annotations.Parameter; +import konkuk.chacall.domain.chat.application.ChatService; +import konkuk.chacall.domain.chat.presentation.dto.request.SendChatMessageRequest; +import konkuk.chacall.domain.chat.presentation.dto.response.ChatMessageResponse; +import konkuk.chacall.global.common.annotation.UserId; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Slf4j +@Controller +@RequiredArgsConstructor public class ChatController { + + private final ChatService chatService; + private final SimpMessagingTemplate messagingTemplate; + + @MessageMapping("/rooms/{roomId}") // /pub/rooms/{roomId} + public void sendMessage( + @DestinationVariable Long roomId, + @Parameter(hidden = true) @UserId final Long userId, + SendChatMessageRequest request + ){ + ChatMessageResponse chatMessageResponse = chatService.sendMessage(roomId, userId, request); + messagingTemplate.convertAndSend("/sub/rooms/" + roomId, chatMessageResponse); + } } diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/ChatRestController.java b/src/main/java/konkuk/chacall/domain/chat/presentation/ChatRestController.java new file mode 100644 index 00000000..26e30e93 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/ChatRestController.java @@ -0,0 +1,110 @@ +package konkuk.chacall.domain.chat.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import konkuk.chacall.domain.chat.application.ChatService; +import konkuk.chacall.domain.chat.presentation.dto.request.CreateChatRoomRequest; +import konkuk.chacall.domain.chat.presentation.dto.request.GetChatRoomRequest; +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.global.common.annotation.ExceptionDescription; +import konkuk.chacall.global.common.annotation.UserId; +import konkuk.chacall.global.common.dto.BaseResponse; +import konkuk.chacall.global.common.dto.CursorPagingResponse; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static konkuk.chacall.global.common.swagger.SwaggerResponseDescription.*; + +@Tag(name = "Chat API", description = "채팅 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/chat") +public class ChatRestController { + + private final ChatService chatService; + + @Operation( + summary = "채팅방 생성 (채팅 시작)", + description = "예약자와 사장님간의 채팅방을 생성합니다." + ) + @ExceptionDescription(CREATE_CHAT_ROOM) + @PostMapping("/rooms") + public BaseResponse createChatRoom( + @Parameter(hidden = true) @UserId final Long memberId, + @RequestBody @Valid final CreateChatRoomRequest request + ) { + return BaseResponse.ok( + chatService.createChatRoom(memberId, request.foodTruckId()) + ); + } + + @Operation( + summary = "채팅방 메타데이터 조회", + description = "채팅 상단에 표시되는 채팅 상대의 이름과 관련된 예약 ID(있는 경우)를 조회합니다." + ) + @ExceptionDescription(GET_CHAT_ROOM_META_DATA) + @GetMapping("/rooms/{roomId}") + public BaseResponse getChatRoomMetaData( + @Parameter(hidden = true) @UserId final Long memberId, + @Parameter(description = "채팅방 ID", example = "1") @PathVariable final Long roomId, + @Parameter(description = "현재 채팅방 기준 푸드트럭 사장인지 여부", example = "false") + @RequestParam final Boolean isOwner + ) { + return BaseResponse.ok( + chatService.getChatRoomMetaData(memberId, roomId, isOwner) + ); + } + + @Operation( + summary = "메시지 내역 조회", + description = "특정 채팅방의 메시지 내역을 조회합니다." + ) + @GetMapping("/rooms/{roomId}/messages") + public BaseResponse> getChatMessages( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "채팅방 ID", example = "1") @PathVariable final Long roomId, + @Parameter(description = "페이지 번호", example = "0") @RequestParam @NotNull(message = "페이지 번호는 필수입니다.") + final Integer page, + @Parameter(description = "페이지 크기", example = "20") @RequestParam @NotNull(message = "페이지 크기는 필수입니다.") + final Integer size + ) { + return BaseResponse.ok( + chatService.getChatMessages(userId, roomId, page, size) + ); + } + + @Operation( + summary = "채팅방 목록 조회", + description = "사용자가 속한 채팅방 목록을 조회합니다." + ) + @GetMapping("/rooms") + public BaseResponse> getChatRooms( + @Parameter(hidden = true) @UserId final Long userId, + @Valid @ParameterObject final GetChatRoomRequest request + ) { + return BaseResponse.ok( + chatService.getChatRooms(userId, request) + ); + } + + @Operation( + summary = "채팅방 내 메시지 읽음 처리" + ) + @PatchMapping("/rooms/{roomId}/read") + public BaseResponse markMessagesAsRead( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "채팅방 ID", example = "1") @PathVariable final Long roomId + ) { + chatService.markMessagesAsRead(userId, roomId); + return BaseResponse.ok(null); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/CreateChatRoomRequest.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/CreateChatRoomRequest.java new file mode 100644 index 00000000..f0330d3d --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/CreateChatRoomRequest.java @@ -0,0 +1,11 @@ +package konkuk.chacall.domain.chat.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record CreateChatRoomRequest( + @Schema(description = "푸드트럭 ID", example = "1") + @NotNull(message = "푸드트럭 ID는 필수입니다.") + Long foodTruckId +) { +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/GetChatRoomRequest.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/GetChatRoomRequest.java new file mode 100644 index 00000000..c8cf9c3d --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/GetChatRoomRequest.java @@ -0,0 +1,17 @@ +package konkuk.chacall.domain.chat.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import konkuk.chacall.global.common.dto.CursorPagingRequest; +import konkuk.chacall.global.common.dto.HasPaging; + +public record GetChatRoomRequest( + @Schema(description = "현재 채팅방 기준 푸드트럭 사장인지 여부", example = "false") + @NotNull(message = "isOwner 는 null 일 수 없습니다.") + Boolean isOwner, + + @Valid + CursorPagingRequest cursorPagingRequest +) implements HasPaging +{ } diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/SendChatMessageRequest.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/SendChatMessageRequest.java new file mode 100644 index 00000000..6af00a2b --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/request/SendChatMessageRequest.java @@ -0,0 +1,16 @@ +package konkuk.chacall.domain.chat.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import konkuk.chacall.domain.chat.domain.value.MessageContentType; + +public record SendChatMessageRequest( + @Schema(description = "메시지 내용", example = "안녕하세요!") + @NotBlank(message = "메시지 내용은 필수입니다.") + String content, + @Schema(description = "메시지 타입 (TEXT or IMAGE)", example = "TEXT") + @NotNull(message = "메시지 타입은 필수입니다.") + MessageContentType contentType +) { +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatMessageResponse.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatMessageResponse.java new file mode 100644 index 00000000..ba852bcf --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatMessageResponse.java @@ -0,0 +1,25 @@ +package konkuk.chacall.domain.chat.presentation.dto.response; + +import konkuk.chacall.domain.chat.domain.ChatMessage; + +import java.time.LocalDateTime; + +public record ChatMessageResponse( + Long roomId, + Long senderId, + String content, + String contentType, + LocalDateTime sendTime, + boolean read +) { + public static ChatMessageResponse from(ChatMessage chatMessage) { + return new ChatMessageResponse( + chatMessage.getRoomId(), + chatMessage.getSenderId(), + chatMessage.getContent(), + chatMessage.getContentType(), + chatMessage.getSendTime(), + chatMessage.isRead() + ); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomIdResponse.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomIdResponse.java new file mode 100644 index 00000000..fe632c83 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomIdResponse.java @@ -0,0 +1,13 @@ +package konkuk.chacall.domain.chat.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import konkuk.chacall.domain.chat.domain.ChatRoom; + +public record ChatRoomIdResponse( + @Schema(description = "생성된 채팅방 ID", example = "1") + Long chatRoomId +) { + public static ChatRoomIdResponse of(ChatRoom chatRoom) { + return new ChatRoomIdResponse(chatRoom.getChatRoomId()); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomMetaDataResponse.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomMetaDataResponse.java new file mode 100644 index 00000000..3e5e4521 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomMetaDataResponse.java @@ -0,0 +1,16 @@ +package konkuk.chacall.domain.chat.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ChatRoomMetaDataResponse( + @Schema(description = "채팅 상대 이름 (일반 유저 -> 사장님 이름 / 사장님 -> 예약자 이름)", example = "홍길동 or 푸드트럭사장") + String name, + @Schema(description = "푸드트럭 이름 (일반 유저 -> null)", example = "맛있는푸드트럭") + String foodTruckName, + @Schema(description = "채팅방과 관련된 예약 ID (있는 경우: ID 반환, 없는 경우: null)", example = "1") + Long reservationId +) { + public static ChatRoomMetaDataResponse of(String name, String foodTruckName, Long reservationId) { + return new ChatRoomMetaDataResponse(name, foodTruckName, reservationId); + } +} diff --git a/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomResponse.java b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomResponse.java new file mode 100644 index 00000000..4ac88f33 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/chat/presentation/dto/response/ChatRoomResponse.java @@ -0,0 +1,56 @@ +package konkuk.chacall.domain.chat.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import konkuk.chacall.domain.chat.domain.ChatRoom; +import konkuk.chacall.domain.chat.domain.repository.dto.ChatRoomMetaDataProjection; +import konkuk.chacall.domain.user.domain.model.User; +import konkuk.chacall.global.common.util.DateUtil; + +import java.util.Optional; + +public record ChatRoomResponse( + @Schema(description = "채팅방 ID", example = "1") + Long id, + @Schema(description = "상대방 이름", example = "홍길동") + String name, + @Schema(description = "푸드트럭 이름", example = "맛있는 푸드트럭") + String foodTruckName, + @Schema(description = "상대방 프로필 이미지 URL", example = "https://example.com/profile.jpg") + String profileImageUrl, + @Schema(description = "마지막 메시지 내용", example = "안녕하세요!") + String lastMessage, + @Schema(description = "마지막 메시지 전송 시간", example = "오후 5:49 or 어제 or 9월 30일 or 2023년 10월") + String lastMessageSendTime, + @Schema(description = "읽지 않은 메시지 수", example = "3") + long unreadCount, + @Schema(description = "예약 확정 여부", example = "true") + boolean isReservationConfirmed +) { + + public static ChatRoomResponse from(ChatRoom chatRoom, ChatRoomMetaDataProjection meta, boolean isOwner, boolean isReservationConfirmed) { + // 현재 뷰 기준 상대방 정보 + User oppenent = isOwner ? chatRoom.getMember() : chatRoom.getFoodTruck().getOwner(); + String name = oppenent.getName(); + + String foodTruckName = chatRoom.getFoodTruck().getFoodTruckInfo().getName(); + String profileImageUrl = oppenent.getProfileImageUrl(); + + String lastMessage = meta.getLastMessage(); + String lastMessageSendTime = (meta.getLastMessageSendTime() != null) + ? DateUtil.formatLocalDateTime(meta.getLastMessageSendTime()) + : null; + + long unreadCount = Optional.ofNullable(meta.getUnreadCount()).orElse(0L); + + return new ChatRoomResponse( + chatRoom.getChatRoomId(), + name, + foodTruckName, + profileImageUrl, + lastMessage, + lastMessageSendTime, + unreadCount, + isReservationConfirmed + ); + } +} diff --git a/src/main/java/konkuk/chacall/domain/reservation/application/info/ReservationInfoService.java b/src/main/java/konkuk/chacall/domain/reservation/application/info/ReservationInfoService.java index 886c327f..72eb0282 100644 --- a/src/main/java/konkuk/chacall/domain/reservation/application/info/ReservationInfoService.java +++ b/src/main/java/konkuk/chacall/domain/reservation/application/info/ReservationInfoService.java @@ -1,5 +1,7 @@ package konkuk.chacall.domain.reservation.application.info; +import konkuk.chacall.domain.chat.domain.ChatRoom; +import konkuk.chacall.domain.chat.domain.repository.ChatRoomRepository; import konkuk.chacall.domain.foodtruck.domain.model.FoodTruck; import konkuk.chacall.domain.foodtruck.domain.repository.FoodTruckRepository; import konkuk.chacall.domain.reservation.domain.model.Reservation; @@ -20,11 +22,15 @@ public class ReservationInfoService { private final FoodTruckRepository foodTruckRepository; private final ReservationRepository reservationRepository; + private final ChatRoomRepository chatRoomRepository; public Long createReservation(CreateReservationRequest request, User owner, User member) { FoodTruck foodTruck = foodTruckRepository.findById(request.foodTruckId()) .orElseThrow(() -> new EntityNotFoundException(ErrorCode.FOOD_TRUCK_NOT_FOUND)); + ChatRoom chatRoom = chatRoomRepository.findById(request.chatRoomId()) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.CHAT_ROOM_NOT_FOUND)); + Reservation reservation = Reservation.create( request.address(), request.detailAddress(), @@ -36,7 +42,9 @@ public Long createReservation(CreateReservationRequest request, User owner, User request.etcRequest(), owner, member, - foodTruck); + foodTruck, + chatRoom + ); return reservationRepository.save(reservation).getReservationId(); } diff --git a/src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java b/src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java index 90f023b0..93272a82 100644 --- a/src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java +++ b/src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java @@ -1,6 +1,7 @@ package konkuk.chacall.domain.reservation.domain.model; import jakarta.persistence.*; +import konkuk.chacall.domain.chat.domain.ChatRoom; import konkuk.chacall.domain.reservation.domain.value.ReservationDateList; import konkuk.chacall.domain.foodtruck.domain.model.FoodTruck; import konkuk.chacall.domain.reservation.domain.value.ReservationInfo; @@ -47,6 +48,11 @@ public class Reservation extends BaseEntity { @JoinColumn(name = "food_truck_id", nullable = false) private FoodTruck foodTruck; + // 추후에 nullable = false 로 변경할 예정 + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", unique = true) + private ChatRoom chatRoom; + // 해당 예약과 연관된 사람인지 검증 (사장님, 예약자) public void validateAccessibleBy(Long userId) { if (!isForFoodTruckOwnedBy(userId) && !isReservedBy(userId)) { @@ -99,7 +105,8 @@ public static Reservation create( String etcRequest, User owner, User member, - FoodTruck foodTruck + FoodTruck foodTruck, + ChatRoom chatRoom ) { validateCreateReservation(owner, member, foodTruck); @@ -121,6 +128,7 @@ public static Reservation create( .pdfUrl(null) .member(member) .foodTruck(foodTruck) + .chatRoom(chatRoom) .build(); } diff --git a/src/main/java/konkuk/chacall/domain/reservation/domain/repository/ReservationRepository.java b/src/main/java/konkuk/chacall/domain/reservation/domain/repository/ReservationRepository.java index 30fd3d7c..2a6583c9 100644 --- a/src/main/java/konkuk/chacall/domain/reservation/domain/repository/ReservationRepository.java +++ b/src/main/java/konkuk/chacall/domain/reservation/domain/repository/ReservationRepository.java @@ -1,6 +1,8 @@ package konkuk.chacall.domain.reservation.domain.repository; +import konkuk.chacall.domain.chat.domain.ChatRoom; import konkuk.chacall.domain.reservation.domain.model.Reservation; +import konkuk.chacall.domain.reservation.domain.repository.dto.ReservationConfirmedProjection; import konkuk.chacall.domain.reservation.domain.value.ReservationStatus; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -10,6 +12,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -48,4 +51,12 @@ Slice findMemberReservationsByStatusWithCursor( @Modifying @Query("DELETE FROM Reservation r WHERE r.foodTruck.foodTruckId = :foodTruckId") void deleteAllByFoodTruckId(@Param("foodTruckId") Long foodTruckId); + + Optional findByChatRoom(ChatRoom chatRoom); + + @Query("SELECT r.chatRoom.chatRoomId AS roomId, " + + "CASE WHEN r.reservationStatus = 'CONFIRMED' THEN true ELSE false END AS confirmed " + + "FROM Reservation r " + + "WHERE r.chatRoom.chatRoomId IN :roomIds") + List findReservationConfirmedByChatRoomIds(@Param("roomIds") List roomIds); } diff --git a/src/main/java/konkuk/chacall/domain/reservation/domain/repository/dto/ReservationConfirmedProjection.java b/src/main/java/konkuk/chacall/domain/reservation/domain/repository/dto/ReservationConfirmedProjection.java new file mode 100644 index 00000000..e63b21a7 --- /dev/null +++ b/src/main/java/konkuk/chacall/domain/reservation/domain/repository/dto/ReservationConfirmedProjection.java @@ -0,0 +1,8 @@ +package konkuk.chacall.domain.reservation.domain.repository.dto; + +public interface ReservationConfirmedProjection { + + Long getRoomId(); + + Boolean getConfirmed(); +} \ No newline at end of file diff --git a/src/main/java/konkuk/chacall/domain/reservation/presentation/dto/request/CreateReservationRequest.java b/src/main/java/konkuk/chacall/domain/reservation/presentation/dto/request/CreateReservationRequest.java index d35ee4cc..e04801a4 100644 --- a/src/main/java/konkuk/chacall/domain/reservation/presentation/dto/request/CreateReservationRequest.java +++ b/src/main/java/konkuk/chacall/domain/reservation/presentation/dto/request/CreateReservationRequest.java @@ -10,6 +10,10 @@ public record CreateReservationRequest( @NotNull(message = "푸드트럭 ID는 필수 입력 값입니다.") Long foodTruckId, + @Schema(description = "채팅방 ID", example = "1") + @NotNull(message = "채팅방 ID는 필수 입력 값입니다.") + Long chatRoomId, + @Schema(description = "예약자(일반 유저) ID", example = "2") @NotNull(message = "예약자 ID는 필수 입력 값입니다.") Long reservationUserId, diff --git a/src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java b/src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java index b29ff399..9be85ed1 100644 --- a/src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java @@ -113,7 +113,15 @@ public enum ErrorCode implements ResponseCode { /** * FoodTruckServiceArea */ - FOOD_TRUCK_SERVICE_AREA_NOT_FOUND(HttpStatus.NOT_FOUND, 150001, "푸드트럭 서비스 지역을 찾을 수 없습니다.") + FOOD_TRUCK_SERVICE_AREA_NOT_FOUND(HttpStatus.NOT_FOUND, 150001, "푸드트럭 서비스 지역을 찾을 수 없습니다."), + + /** + * ChatRoom + */ + CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 160001, "채팅방을 찾을 수 없습니다."), + CHAT_ROOM_ALREADY_EXISTS(HttpStatus.CONFLICT, 160002, "이미 존재하는 채팅방입니다."), + CHAT_ROOM_FORBIDDEN(HttpStatus.FORBIDDEN, 160003, "채팅방 접근 권한이 없습니다."), + CHAT_ROOM_FILTER_MISMATCH(HttpStatus.BAD_REQUEST, 160004, "채팅방 필터 값이 올바르지 않습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/konkuk/chacall/global/common/security/interceptor/StompAuthChannelInterceptor.java b/src/main/java/konkuk/chacall/global/common/security/interceptor/StompAuthChannelInterceptor.java new file mode 100644 index 00000000..32ee5e39 --- /dev/null +++ b/src/main/java/konkuk/chacall/global/common/security/interceptor/StompAuthChannelInterceptor.java @@ -0,0 +1,46 @@ +package konkuk.chacall.global.common.security.interceptor; + +import konkuk.chacall.global.common.exception.AuthException; +import konkuk.chacall.global.common.exception.code.ErrorCode; +import konkuk.chacall.global.common.security.oauth2.LoginUser; +import konkuk.chacall.global.common.security.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +import static konkuk.chacall.global.common.security.constant.AuthParameters.*; + +@Component +@RequiredArgsConstructor +public class StompAuthChannelInterceptor implements ChannelInterceptor { + + private final JwtUtil jwtUtil; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + // CONNECT 요청에 대해서 인증 처리 & JWT 토큰 검증 + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String authorization = accessor.getFirstNativeHeader(JWT_HEADER_KEY.getValue()); + if (authorization == null || !authorization.startsWith(JWT_PREFIX.getValue())) { + throw new AuthException(ErrorCode.AUTH_TOKEN_NOT_FOUND); + } + + String token = authorization.split(" ")[1]; + LoginUser loginUser = jwtUtil.getLoginUser(token); + + // ArgumentsResolver를 위해 세션에 userId 저장 + accessor.getSessionAttributes() + .put(JWT_ACCESS_TOKEN_KEY.getValue(), loginUser.userId()); + } + + return message; + } +} diff --git a/src/main/java/konkuk/chacall/global/common/security/resolver/StompUserIdArgumentResolver.java b/src/main/java/konkuk/chacall/global/common/security/resolver/StompUserIdArgumentResolver.java new file mode 100644 index 00000000..5f3254a3 --- /dev/null +++ b/src/main/java/konkuk/chacall/global/common/security/resolver/StompUserIdArgumentResolver.java @@ -0,0 +1,46 @@ +package konkuk.chacall.global.common.security.resolver; + +import konkuk.chacall.global.common.annotation.UserId; +import konkuk.chacall.global.common.exception.AuthException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +import static konkuk.chacall.global.common.exception.code.ErrorCode.AUTH_TOKEN_NOT_FOUND; +import static konkuk.chacall.global.common.security.constant.AuthParameters.JWT_ACCESS_TOKEN_KEY; + +@Component +@RequiredArgsConstructor +public class StompUserIdArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Long resolveArgument(MethodParameter parameter, Message message) { + + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor == null || accessor.getSessionAttributes() == null) { + throw new AuthException(AUTH_TOKEN_NOT_FOUND); + } + + Object userId = accessor.getSessionAttributes() + .get(JWT_ACCESS_TOKEN_KEY.getValue()); + + if (userId == null) { + throw new AuthException(AUTH_TOKEN_NOT_FOUND); + } + + return (Long) userId; + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java index 437f441f..a74bbc6f 100644 --- a/src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java @@ -265,6 +265,19 @@ public enum SwaggerResponseDescription { FOOD_TRUCK_STATUS_MISMATCH ))), + // Chat + CREATE_CHAT_ROOM(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + USER_FORBIDDEN, + FOOD_TRUCK_NOT_FOUND, + CHAT_ROOM_ALREADY_EXISTS + ))), + GET_CHAT_ROOM_META_DATA(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + USER_FORBIDDEN, + CHAT_ROOM_NOT_FOUND + ))), + // Default DEFAULT(new LinkedHashSet<>()) ; diff --git a/src/main/java/konkuk/chacall/global/common/util/DateUtil.java b/src/main/java/konkuk/chacall/global/common/util/DateUtil.java new file mode 100644 index 00000000..454386ce --- /dev/null +++ b/src/main/java/konkuk/chacall/global/common/util/DateUtil.java @@ -0,0 +1,32 @@ +package konkuk.chacall.global.common.util; + +import java.time.Duration; +import java.time.LocalDateTime; + +public class DateUtil { + // LocalDateTime을 "오후 hh:mm" 형식의 문자열로 변환하는 메서드 (하루가 지난 경우 "어제" 이틀 이상이 지난 경우 "MM월 dd일" 1년 이상이 지난 경우 "yyyy년 MM월" 형식) + public static String formatLocalDateTime(LocalDateTime dateTime) { + LocalDateTime now = LocalDateTime.now(); + Duration duration = Duration.between(dateTime, now); + + if (duration.toDays() < 1) { + // 오늘 + int hour = dateTime.getHour(); + String period = (hour >= 12) ? "오후" : "오전"; + hour = (hour > 12) ? hour - 12 : hour; + if (hour == 0) hour = 12; // 12시 처리 + int minute = dateTime.getMinute(); + return String.format("%s %d:%02d", period, hour, minute); + } else if (duration.toDays() < 2) { + // 어제 + return "어제"; + } else if (duration.toDays() < 365) { + // 올해 + return String.format("%d월 %d일", dateTime.getMonthValue(), dateTime.getDayOfMonth()); + } else { + // 1년 이상 + return String.format("%d년 %d월", dateTime.getYear(), dateTime.getMonthValue()); + } + } + +} diff --git a/src/main/java/konkuk/chacall/global/config/WebSocketConfig.java b/src/main/java/konkuk/chacall/global/config/WebSocketConfig.java new file mode 100644 index 00000000..be24980b --- /dev/null +++ b/src/main/java/konkuk/chacall/global/config/WebSocketConfig.java @@ -0,0 +1,46 @@ +package konkuk.chacall.global.config; + +import konkuk.chacall.global.common.security.interceptor.StompAuthChannelInterceptor; +import konkuk.chacall.global.common.security.resolver.StompUserIdArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import java.util.List; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompAuthChannelInterceptor stompAuthChannelInterceptor; + private final StompUserIdArgumentResolver stompUserIdArgumentResolver; + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompAuthChannelInterceptor); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(stompUserIdArgumentResolver); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*"); +// .withSockJS(); // SockJS 사용 시 + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/pub"); // 발신 prefix + registry.enableSimpleBroker("/sub"); // 수신 prefix + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 42ea5f82..d849f437 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -21,6 +21,9 @@ spring: redis: host: ${REDIS_HOST} port: 6379 + mongodb: + uri: ${MONGODB_URI} + database: ${MONGODB_DATABASE} logging: level: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 8f008935..992b84fb 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -21,7 +21,13 @@ spring: redis: host: localhost port: 6379 - + mongodb: + host: localhost + port: 27017 + database: chacall + username: ${MONGODB_USERNAME} + password: ${MONGODB_PASSWORD} + authentication-database: chacall --- logging: level: