From b6921cc65f9f75473387e648b7804240cba2764b Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:21:25 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EB=B6=81=EB=A7=88=ED=81=AC=20CRU?= =?UTF-8?q?D=20API=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../malmo/adaptor/in/exception/ErrorCode.java | 3 + .../in/exception/GlobalExceptionHandler.java | 18 + .../in/web/controller/BookmarkController.java | 163 ++++++ .../adapter/BookmarkPersistenceAdapter.java | 79 +++ .../adapter/ChatRoomPersistenceAdapter.java | 14 +- .../entity/chat/BookmarkEntity.java | 45 ++ .../persistence/mapper/BookmarkMapper.java | 46 ++ .../repository/chat/BookmarkRepository.java | 18 + .../chat/BookmarkRepositoryCustom.java | 19 + .../chat/BookmarkRepositoryCustomImpl.java | 98 ++++ .../chat/ChatMessageRepositoryCustom.java | 4 +- .../chat/ChatMessageRepositoryCustomImpl.java | 32 +- .../BookmarkAlreadyExistsException.java | 12 + .../exception/BookmarkNotFoundException.java | 12 + .../exception/MessageNotFoundException.java | 12 + .../chat_room/BookmarkCommandHelper.java | 26 + .../helper/chat_room/BookmarkQueryHelper.java | 68 +++ .../helper/chat_room/ChatRoomQueryHelper.java | 8 +- .../port/in/chat/CreateBookmarkUseCase.java | 29 + .../port/in/chat/DeleteBookmarksUseCase.java | 19 + .../port/in/chat/GetBookmarkListUseCase.java | 38 ++ .../in/chat/GetMessagesByBookmarkUseCase.java | 43 ++ .../port/out/chat/DeleteBookmarkPort.java | 10 + .../port/out/chat/LoadBookmarkPort.java | 40 ++ .../port/out/chat/LoadMessagesPort.java | 7 +- .../port/out/chat/SaveBookmarkPort.java | 8 + .../service/chat/BookmarkService.java | 167 ++++++ .../service/chat/ChatRoomService.java | 5 +- .../service/chat/CurrentChatRoomService.java | 5 +- .../cmc/malmo/domain/model/chat/Bookmark.java | 55 ++ .../cmc/malmo/domain/value/id/BookmarkId.java | 12 + .../domain/value/state/BookmarkState.java | 6 + .../BookmarkIntegrationTest.java | 502 ++++++++++++++++++ .../BookmarkRequestDtoFactory.java | 18 + 34 files changed, 1607 insertions(+), 34 deletions(-) create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java create mode 100644 src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java create mode 100644 src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java create mode 100644 src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java create mode 100644 src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java create mode 100644 src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java create mode 100644 src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java create mode 100644 src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java create mode 100644 src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java create mode 100644 src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java create mode 100644 src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java create mode 100644 src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java index 0dff9355..50cd2382 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java @@ -23,6 +23,9 @@ public enum ErrorCode { NOT_VALID_COUPLE_CODE(HttpStatus.BAD_REQUEST, 40011, "유효하지 않은 커플 코드입니다."), NO_SUCH_COUPLE_QUESTION(HttpStatus.BAD_REQUEST, 40012, "커플 질문이 존재하지 않습니다."), NO_SUCH_TEMP_LOVE_TYPE(HttpStatus.BAD_REQUEST, 40013, "애착 유형 결과가 존재하지 않습니다."), + NO_SUCH_BOOKMARK(HttpStatus.BAD_REQUEST, 40014, "북마크가 존재하지 않습니다."), + BOOKMARK_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, 40015, "이미 북마크된 메시지입니다."), + NO_SUCH_MESSAGE(HttpStatus.BAD_REQUEST, 40016, "메시지가 존재하지 않습니다."), // 401 Unauthorized UNAUTHORIZED(HttpStatus.UNAUTHORIZED, 40100, "인증되지 않은 사용자입니다."), diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java index 46f81fc9..149dc51d 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java @@ -150,6 +150,24 @@ public ResponseEntity handleTempLoveTypeNotFoundException(TempLov return ErrorResponse.of(ErrorCode.NO_SUCH_TEMP_LOVE_TYPE); } + @ExceptionHandler({BookmarkNotFoundException.class}) + public ResponseEntity handleBookmarkNotFoundException(BookmarkNotFoundException e) { + log.warn("[GlobalExceptionHandler: handleBookmarkNotFoundException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.NO_SUCH_BOOKMARK); + } + + @ExceptionHandler({BookmarkAlreadyExistsException.class}) + public ResponseEntity handleBookmarkAlreadyExistsException(BookmarkAlreadyExistsException e) { + log.info("[GlobalExceptionHandler: handleBookmarkAlreadyExistsException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.BOOKMARK_ALREADY_EXISTS); + } + + @ExceptionHandler({MessageNotFoundException.class}) + public ResponseEntity handleMessageNotFoundException(MessageNotFoundException e) { + log.warn("[GlobalExceptionHandler: handleMessageNotFoundException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.NO_SUCH_MESSAGE); + } + /** diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java new file mode 100644 index 00000000..594dc00a --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java @@ -0,0 +1,163 @@ +package makeus.cmc.malmo.adaptor.in.web.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.in.web.docs.ApiCommonResponses; +import makeus.cmc.malmo.adaptor.in.web.dto.BaseListResponse; +import makeus.cmc.malmo.adaptor.in.web.dto.BaseResponse; +import makeus.cmc.malmo.application.port.in.chat.CreateBookmarkUseCase; +import makeus.cmc.malmo.application.port.in.chat.DeleteBookmarksUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetBookmarkListUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetMessagesByBookmarkUseCase; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "북마크 API", description = "채팅 메시지 북마크 관리를 위한 API") +@RestController +@RequestMapping("/chatrooms/{chatRoomId}/bookmarks") +@RequiredArgsConstructor +public class BookmarkController { + + private final CreateBookmarkUseCase createBookmarkUseCase; + private final DeleteBookmarksUseCase deleteBookmarksUseCase; + private final GetBookmarkListUseCase getBookmarkListUseCase; + private final GetMessagesByBookmarkUseCase getMessagesByBookmarkUseCase; + + @Operation( + summary = "북마크 생성", + description = "채팅 메시지를 북마크합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 생성 성공" + ) + @ApiCommonResponses.RequireAuth + @PostMapping + public BaseResponse createBookmark( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @RequestBody CreateBookmarkRequestDto requestDto) { + + CreateBookmarkUseCase.CreateBookmarkCommand command = + CreateBookmarkUseCase.CreateBookmarkCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .messageId(requestDto.getMessageId()) + .build(); + + return BaseResponse.success(createBookmarkUseCase.createBookmark(command)); + } + + @Operation( + summary = "북마크 삭제 (다건)", + description = "북마크를 삭제합니다 (소프트 삭제). JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 삭제 성공" + ) + @ApiCommonResponses.RequireAuth + @DeleteMapping + public BaseResponse deleteBookmarks( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @RequestBody DeleteBookmarksRequestDto requestDto) { + + DeleteBookmarksUseCase.DeleteBookmarksCommand command = + DeleteBookmarksUseCase.DeleteBookmarksCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .bookmarkIdList(requestDto.getBookmarkIdList()) + .build(); + + deleteBookmarksUseCase.deleteBookmarks(command); + return BaseResponse.success(null); + } + + @Operation( + summary = "북마크 리스트 조회", + description = "채팅방의 북마크 목록을 조회합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 리스트 조회 성공" + ) + @ApiCommonResponses.RequireAuth + @GetMapping + public BaseResponse> getBookmarkList( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + + GetBookmarkListUseCase.GetBookmarkListCommand command = + GetBookmarkListUseCase.GetBookmarkListCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .pageable(pageable) + .build(); + + GetBookmarkListUseCase.GetBookmarkListResponse response = + getBookmarkListUseCase.getBookmarkList(command); + + return BaseListResponse.success(response.getBookmarkList(), response.getTotalCount()); + } + + @Operation( + summary = "북마크 기반 메시지 조회", + description = "북마크된 메시지 위치로 이동하기 위한 메시지 목록을 조회합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 기반 메시지 조회 성공" + ) + @ApiCommonResponses.RequireAuth + @GetMapping("/{bookmarkId}/messages") + public BaseResponse getMessagesByBookmark( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @PathVariable Long bookmarkId, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "ASC") String sort) { + + GetMessagesByBookmarkUseCase.GetMessagesByBookmarkCommand command = + GetMessagesByBookmarkUseCase.GetMessagesByBookmarkCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .bookmarkId(bookmarkId) + .size(size) + .sort(sort) + .build(); + + return BaseResponse.success(getMessagesByBookmarkUseCase.getMessagesByBookmark(command)); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CreateBookmarkRequestDto { + private Long messageId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class DeleteBookmarksRequestDto { + private List bookmarkIdList; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java new file mode 100644 index 00000000..d1980aca --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java @@ -0,0 +1,79 @@ +package makeus.cmc.malmo.adaptor.out.persistence.adapter; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.adaptor.out.persistence.mapper.BookmarkMapper; +import makeus.cmc.malmo.adaptor.out.persistence.repository.chat.BookmarkRepository; +import makeus.cmc.malmo.application.port.out.chat.DeleteBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.SaveBookmarkPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class BookmarkPersistenceAdapter implements LoadBookmarkPort, SaveBookmarkPort, DeleteBookmarkPort { + + private final BookmarkRepository bookmarkRepository; + private final BookmarkMapper bookmarkMapper; + + @Override + public Optional loadBookmarkById(BookmarkId bookmarkId) { + return bookmarkRepository.findByIdAndBookmarkState(bookmarkId.getValue(), BookmarkState.ALIVE) + .map(bookmarkMapper::toDomain); + } + + @Override + public Optional loadBookmarkByMemberAndMessage(MemberId memberId, Long chatMessageId) { + return bookmarkRepository.findByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + memberId.getValue(), chatMessageId, BookmarkState.ALIVE) + .map(bookmarkMapper::toDomain); + } + + @Override + public Page loadBookmarksByMemberAndChatRoom( + MemberId memberId, ChatRoomId chatRoomId, Pageable pageable) { + return bookmarkRepository.loadBookmarksByMemberAndChatRoom( + memberId.getValue(), chatRoomId.getValue(), pageable); + } + + @Override + public boolean existsByMemberAndMessage(MemberId memberId, Long chatMessageId) { + return bookmarkRepository.existsByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + memberId.getValue(), chatMessageId, BookmarkState.ALIVE); + } + + @Override + public boolean isMemberOwnerOfBookmarks(MemberId memberId, List bookmarkIds) { + return bookmarkRepository.isMemberOwnerOfBookmarks( + memberId.getValue(), + bookmarkIds.stream().map(BookmarkId::getValue).toList()); + } + + @Override + public long countMessagesBeforeId(ChatRoomId chatRoomId, Long messageId, String sort) { + return bookmarkRepository.countMessagesBeforeId(chatRoomId.getValue(), messageId, sort); + } + + @Override + public Bookmark saveBookmark(Bookmark bookmark) { + BookmarkEntity entity = bookmarkMapper.toEntity(bookmark); + BookmarkEntity saved = bookmarkRepository.save(entity); + return bookmarkMapper.toDomain(saved); + } + + @Override + public void softDeleteBookmarks(List bookmarkIds) { + bookmarkRepository.softDeleteBookmarks( + bookmarkIds.stream().map(BookmarkId::getValue).toList()); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 3ba16e01..4a732e54 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -31,13 +31,19 @@ public class ChatRoomPersistenceAdapter private final ChatMessageMapper chatMessageMapper; @Override - public Page loadMessagesDto(ChatRoomId chatRoomId, Pageable pageable) { - return chatMessageRepository.loadCurrentMessagesDto(chatRoomId.getValue(), pageable); + public Optional loadMessageById(Long messageId) { + return chatMessageRepository.findById(messageId) + .map(chatMessageMapper::toDomain); } @Override - public Page loadMessagesDtoAsc(ChatRoomId chatRoomId, Pageable pageable) { - return chatMessageRepository.loadCurrentMessagesDtoAsc(chatRoomId.getValue(), pageable); + public Page loadMessagesDto(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return chatMessageRepository.loadCurrentMessagesDto(chatRoomId.getValue(), memberId.getValue(), pageable); + } + + @Override + public Page loadMessagesDtoAsc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return chatMessageRepository.loadCurrentMessagesDtoAsc(chatRoomId.getValue(), memberId.getValue(), pageable); } @Override diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java new file mode 100644 index 00000000..534897cc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java @@ -0,0 +1,45 @@ +package makeus.cmc.malmo.adaptor.out.persistence.entity.chat; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import makeus.cmc.malmo.adaptor.out.persistence.entity.BaseTimeEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatMessageEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatRoomEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.MemberEntityId; +import makeus.cmc.malmo.domain.value.state.BookmarkState; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "bookmark_entity", + indexes = { + @Index(name = "idx_bookmark_member_chatroom", columnList = "member_id, chat_room_id"), + @Index(name = "idx_bookmark_member_message", columnList = "member_id, chat_message_id") + }) +public class BookmarkEntity extends BaseTimeEntity { + + @Column(name = "bookmarkId") + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private ChatRoomEntityId chatRoomEntityId; + + @Embedded + private ChatMessageEntityId chatMessageEntityId; + + @Embedded + private MemberEntityId memberEntityId; + + @Enumerated(EnumType.STRING) + private BookmarkState bookmarkState; + + public void softDelete() { + this.bookmarkState = BookmarkState.DELETED; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java new file mode 100644 index 00000000..bc242f13 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java @@ -0,0 +1,46 @@ +package makeus.cmc.malmo.adaptor.out.persistence.mapper; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatMessageEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatRoomEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.MemberEntityId; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import org.springframework.stereotype.Component; + +@Component +public class BookmarkMapper { + + public Bookmark toDomain(BookmarkEntity entity) { + return Bookmark.from( + entity.getId(), + entity.getChatRoomEntityId() != null + ? ChatRoomId.of(entity.getChatRoomEntityId().getValue()) : null, + entity.getChatMessageEntityId() != null + ? entity.getChatMessageEntityId().getValue() : null, + entity.getMemberEntityId() != null + ? MemberId.of(entity.getMemberEntityId().getValue()) : null, + entity.getBookmarkState(), + entity.getCreatedAt(), + entity.getModifiedAt(), + entity.getDeletedAt() + ); + } + + public BookmarkEntity toEntity(Bookmark domain) { + return BookmarkEntity.builder() + .id(domain.getId()) + .chatRoomEntityId(domain.getChatRoomId() != null + ? ChatRoomEntityId.of(domain.getChatRoomId().getValue()) : null) + .chatMessageEntityId(domain.getChatMessageId() != null + ? ChatMessageEntityId.of(domain.getChatMessageId()) : null) + .memberEntityId(domain.getMemberId() != null + ? MemberEntityId.of(domain.getMemberId().getValue()) : null) + .bookmarkState(domain.getBookmarkState()) + .createdAt(domain.getCreatedAt()) + .modifiedAt(domain.getModifiedAt()) + .deletedAt(domain.getDeletedAt()) + .build(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java new file mode 100644 index 00000000..c02b54d1 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java @@ -0,0 +1,18 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository.chat; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BookmarkRepository extends JpaRepository, BookmarkRepositoryCustom { + + Optional findByIdAndBookmarkState(Long id, BookmarkState state); + + Optional findByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + Long memberId, Long chatMessageId, BookmarkState state); + + boolean existsByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + Long memberId, Long chatMessageId, BookmarkState state); +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java new file mode 100644 index 00000000..6899a2dc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java @@ -0,0 +1,19 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository.chat; + +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface BookmarkRepositoryCustom { + + Page loadBookmarksByMemberAndChatRoom( + Long memberId, Long chatRoomId, Pageable pageable); + + boolean isMemberOwnerOfBookmarks(Long memberId, List bookmarkIds); + + void softDeleteBookmarks(List bookmarkIds); + + long countMessagesBeforeId(Long chatRoomId, Long messageId, String sort); +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java new file mode 100644 index 00000000..f30ce521 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java @@ -0,0 +1,98 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository.chat; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QBookmarkEntity.bookmarkEntity; +import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QChatMessageEntity.chatMessageEntity; + +@RequiredArgsConstructor +public class BookmarkRepositoryCustomImpl implements BookmarkRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page loadBookmarksByMemberAndChatRoom( + Long memberId, Long chatRoomId, Pageable pageable) { + + List content = queryFactory + .select(Projections.constructor(LoadBookmarkPort.BookmarkDto.class, + bookmarkEntity.id, + chatMessageEntity.id, + chatMessageEntity.content, + chatMessageEntity.senderType, + chatMessageEntity.createdAt)) + .from(bookmarkEntity) + .join(chatMessageEntity).on(bookmarkEntity.chatMessageEntityId.value.eq(chatMessageEntity.id)) + .where(bookmarkEntity.memberEntityId.value.eq(memberId) + .and(bookmarkEntity.chatRoomEntityId.value.eq(chatRoomId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) + .orderBy(bookmarkEntity.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(bookmarkEntity.count()) + .from(bookmarkEntity) + .where(bookmarkEntity.memberEntityId.value.eq(memberId) + .and(bookmarkEntity.chatRoomEntityId.value.eq(chatRoomId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + @Override + public boolean isMemberOwnerOfBookmarks(Long memberId, List bookmarkIds) { + Long count = queryFactory + .select(bookmarkEntity.count()) + .from(bookmarkEntity) + .where(bookmarkEntity.id.in(bookmarkIds) + .and(bookmarkEntity.memberEntityId.value.eq(memberId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) + .fetchOne(); + + return count != null && count == bookmarkIds.size(); + } + + @Override + public void softDeleteBookmarks(List bookmarkIds) { + queryFactory + .update(bookmarkEntity) + .set(bookmarkEntity.bookmarkState, BookmarkState.DELETED) + .set(bookmarkEntity.deletedAt, LocalDateTime.now()) + .where(bookmarkEntity.id.in(bookmarkIds)) + .execute(); + } + + @Override + public long countMessagesBeforeId(Long chatRoomId, Long messageId, String sort) { + Long count; + if ("ASC".equalsIgnoreCase(sort)) { + count = queryFactory + .select(chatMessageEntity.count()) + .from(chatMessageEntity) + .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId) + .and(chatMessageEntity.id.lt(messageId))) + .fetchOne(); + } else { + count = queryFactory + .select(chatMessageEntity.count()) + .from(chatMessageEntity) + .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId) + .and(chatMessageEntity.id.gt(messageId))) + .fetchOne(); + } + return count != null ? count : 0L; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java index c9477b50..b4bf0327 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java @@ -5,6 +5,6 @@ import org.springframework.data.domain.Pageable; public interface ChatMessageRepositoryCustom { - Page loadCurrentMessagesDto(Long chatRoomId, Pageable pageable); - Page loadCurrentMessagesDtoAsc(Long chatRoomId, Pageable pageable); + Page loadCurrentMessagesDto(Long chatRoomId, Long memberId, Pageable pageable); + Page loadCurrentMessagesDtoAsc(Long chatRoomId, Long memberId, Pageable pageable); } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java index f76702bf..04b682c3 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java @@ -4,15 +4,15 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; -import makeus.cmc.malmo.domain.value.state.SavedChatMessageState; +import makeus.cmc.malmo.domain.value.state.BookmarkState; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import java.util.List; +import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QBookmarkEntity.bookmarkEntity; import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QChatMessageEntity.chatMessageEntity; -import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QSavedChatMessageEntity.savedChatMessageEntity; @RequiredArgsConstructor public class ChatMessageRepositoryCustomImpl implements ChatMessageRepositoryCustom { @@ -20,18 +20,19 @@ public class ChatMessageRepositoryCustomImpl implements ChatMessageRepositoryCus private final JPAQueryFactory queryFactory; @Override - public Page loadCurrentMessagesDto(Long chatRoomId, Pageable pageable) { + public Page loadCurrentMessagesDto(Long chatRoomId, Long memberId, Pageable pageable) { List content = queryFactory.select(Projections.constructor(LoadMessagesPort.ChatRoomMessageRepositoryDto.class, chatMessageEntity.id, chatMessageEntity.senderType, chatMessageEntity.content, chatMessageEntity.createdAt, - savedChatMessageEntity.isNotNull() + bookmarkEntity.isNotNull() )) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) + .leftJoin(bookmarkEntity) + .on(bookmarkEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) + .and(bookmarkEntity.memberEntityId.value.eq(memberId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .orderBy(chatMessageEntity.createdAt.desc()) .offset(pageable.getOffset()) @@ -40,9 +41,6 @@ public Page loadCurrentMessagesDt long total = queryFactory.select(chatMessageEntity.count()) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .fetchOne(); @@ -50,18 +48,19 @@ public Page loadCurrentMessagesDt } @Override - public Page loadCurrentMessagesDtoAsc(Long chatRoomId, Pageable pageable) { + public Page loadCurrentMessagesDtoAsc(Long chatRoomId, Long memberId, Pageable pageable) { List content = queryFactory.select(Projections.constructor(LoadMessagesPort.ChatRoomMessageRepositoryDto.class, chatMessageEntity.id, chatMessageEntity.senderType, chatMessageEntity.content, chatMessageEntity.createdAt, - savedChatMessageEntity.isNotNull() + bookmarkEntity.isNotNull() )) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) + .leftJoin(bookmarkEntity) + .on(bookmarkEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) + .and(bookmarkEntity.memberEntityId.value.eq(memberId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .orderBy(chatMessageEntity.createdAt.asc()) .offset(pageable.getOffset()) @@ -70,9 +69,6 @@ public Page loadCurrentMessagesDt long total = queryFactory.select(chatMessageEntity.count()) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .fetchOne(); diff --git a/src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java new file mode 100644 index 00000000..2e3a7ae3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.application.exception; + +public class BookmarkAlreadyExistsException extends RuntimeException { + + public BookmarkAlreadyExistsException() { + super("이미 북마크된 메시지입니다."); + } + + public BookmarkAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java new file mode 100644 index 00000000..00a42494 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.application.exception; + +public class BookmarkNotFoundException extends RuntimeException { + + public BookmarkNotFoundException() { + super("북마크가 존재하지 않습니다."); + } + + public BookmarkNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java b/src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java new file mode 100644 index 00000000..2b2dbc04 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.application.exception; + +public class MessageNotFoundException extends RuntimeException { + + public MessageNotFoundException() { + super("메시지가 존재하지 않습니다."); + } + + public MessageNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java new file mode 100644 index 00000000..de90c484 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java @@ -0,0 +1,26 @@ +package makeus.cmc.malmo.application.helper.chat_room; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.port.out.chat.DeleteBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.SaveBookmarkPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BookmarkCommandHelper { + + private final SaveBookmarkPort saveBookmarkPort; + private final DeleteBookmarkPort deleteBookmarkPort; + + public Bookmark saveBookmark(Bookmark bookmark) { + return saveBookmarkPort.saveBookmark(bookmark); + } + + public void softDeleteBookmarks(List bookmarkIds) { + deleteBookmarkPort.softDeleteBookmarks(bookmarkIds); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java new file mode 100644 index 00000000..e1c650c0 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java @@ -0,0 +1,68 @@ +package makeus.cmc.malmo.application.helper.chat_room; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.exception.BookmarkNotFoundException; +import makeus.cmc.malmo.application.exception.MemberAccessDeniedException; +import makeus.cmc.malmo.application.exception.MessageNotFoundException; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BookmarkQueryHelper { + + private final LoadBookmarkPort loadBookmarkPort; + private final LoadMessagesPort loadMessagesPort; + + public Bookmark getBookmarkByIdOrThrow(BookmarkId bookmarkId) { + return loadBookmarkPort.loadBookmarkById(bookmarkId) + .orElseThrow(BookmarkNotFoundException::new); + } + + public boolean existsByMemberAndMessage(MemberId memberId, Long chatMessageId) { + return loadBookmarkPort.existsByMemberAndMessage(memberId, chatMessageId); + } + + public Page getBookmarksByMemberAndChatRoom( + MemberId memberId, ChatRoomId chatRoomId, Pageable pageable) { + return loadBookmarkPort.loadBookmarksByMemberAndChatRoom(memberId, chatRoomId, pageable); + } + + public void validateBookmarksOwnership(MemberId memberId, List bookmarkIds) { + boolean valid = loadBookmarkPort.isMemberOwnerOfBookmarks(memberId, bookmarkIds); + if (!valid) { + throw new MemberAccessDeniedException("북마크에 접근할 권한이 없습니다."); + } + } + + public int calculatePageForMessage(ChatRoomId chatRoomId, Long messageId, int pageSize, String sort) { + long position = loadBookmarkPort.countMessagesBeforeId(chatRoomId, messageId, sort); + return (int) (position / pageSize); + } + + public ChatMessage getMessageByIdOrThrow(Long messageId) { + return loadMessagesPort.loadMessageById(messageId) + .orElseThrow(MessageNotFoundException::new); + } + + public ChatMessage validateMessageInChatRoom(Long messageId, ChatRoomId chatRoomId) { + ChatMessage message = loadMessagesPort.loadMessageById(messageId) + .orElseThrow(MessageNotFoundException::new); + + if (!message.getChatRoomId().getValue().equals(chatRoomId.getValue())) { + throw new MessageNotFoundException("해당 채팅방에 존재하지 않는 메시지입니다."); + } + + return message; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java index b4967c00..6106a126 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java @@ -94,12 +94,12 @@ public void validateChatRoomAlive(MemberId memberId) { 채팅방 메시지 Query Methods */ - public Page getChatMessagesDtoDesc(ChatRoomId chatRoomId, Pageable pageable) { - return loadMessagesPort.loadMessagesDto(chatRoomId, pageable); + public Page getChatMessagesDtoDesc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return loadMessagesPort.loadMessagesDto(chatRoomId, memberId, pageable); } - public Page getChatMessagesDtoAsc(ChatRoomId chatRoomId, Pageable pageable) { - return loadMessagesPort.loadMessagesDtoAsc(chatRoomId, pageable); + public Page getChatMessagesDtoAsc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return loadMessagesPort.loadMessagesDtoAsc(chatRoomId, memberId, pageable); } public List getSummarizedMessages(ChatRoomId chatRoomId) { diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java new file mode 100644 index 00000000..edf0089f --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java @@ -0,0 +1,29 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.SenderType; + +import java.time.LocalDateTime; + +public interface CreateBookmarkUseCase { + + CreateBookmarkResponse createBookmark(CreateBookmarkCommand command); + + @Data + @Builder + class CreateBookmarkCommand { + private Long userId; + private Long chatRoomId; + private Long messageId; + } + + @Data + @Builder + class CreateBookmarkResponse { + private Long bookmarkId; + private String content; + private SenderType type; + private LocalDateTime timestamp; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java new file mode 100644 index 00000000..bb757adc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java @@ -0,0 +1,19 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +public interface DeleteBookmarksUseCase { + + void deleteBookmarks(DeleteBookmarksCommand command); + + @Data + @Builder + class DeleteBookmarksCommand { + private Long userId; + private Long chatRoomId; + private List bookmarkIdList; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java new file mode 100644 index 00000000..026b78cc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java @@ -0,0 +1,38 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.SenderType; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +public interface GetBookmarkListUseCase { + + GetBookmarkListResponse getBookmarkList(GetBookmarkListCommand command); + + @Data + @Builder + class GetBookmarkListCommand { + private Long userId; + private Long chatRoomId; + private Pageable pageable; + } + + @Data + @Builder + class GetBookmarkListResponse { + private List bookmarkList; + private Long totalCount; + } + + @Data + @Builder + class BookmarkDto { + private Long bookmarkId; + private String content; + private SenderType type; + private LocalDateTime timestamp; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java new file mode 100644 index 00000000..a650ded2 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java @@ -0,0 +1,43 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.SenderType; + +import java.time.LocalDateTime; +import java.util.List; + +public interface GetMessagesByBookmarkUseCase { + + GetMessagesByBookmarkResponse getMessagesByBookmark(GetMessagesByBookmarkCommand command); + + @Data + @Builder + class GetMessagesByBookmarkCommand { + private Long userId; + private Long bookmarkId; + private int size; + private String sort; + } + + @Data + @Builder + class GetMessagesByBookmarkResponse { + private Long targetMessageId; + private int size; + private int page; + private List messages; + } + + @Data + @Builder + class MessageDto { + private Long messageId; + private String content; + private SenderType senderType; + private LocalDateTime createdAt; + @JsonProperty("isSaved") + private boolean isSaved; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java new file mode 100644 index 00000000..3605dbf3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java @@ -0,0 +1,10 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import makeus.cmc.malmo.domain.value.id.BookmarkId; + +import java.util.List; + +public interface DeleteBookmarkPort { + + void softDeleteBookmarks(List bookmarkIds); +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java new file mode 100644 index 00000000..97f23197 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java @@ -0,0 +1,40 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import lombok.AllArgsConstructor; +import lombok.Data; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.type.SenderType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface LoadBookmarkPort { + + Optional loadBookmarkById(BookmarkId bookmarkId); + + Optional loadBookmarkByMemberAndMessage(MemberId memberId, Long chatMessageId); + + Page loadBookmarksByMemberAndChatRoom(MemberId memberId, ChatRoomId chatRoomId, Pageable pageable); + + boolean existsByMemberAndMessage(MemberId memberId, Long chatMessageId); + + boolean isMemberOwnerOfBookmarks(MemberId memberId, List bookmarkIds); + + long countMessagesBeforeId(ChatRoomId chatRoomId, Long messageId, String sort); + + @Data + @AllArgsConstructor + class BookmarkDto { + private Long bookmarkId; + private Long chatMessageId; + private String content; + private SenderType senderType; + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java index 31a170fe..1fdd91b0 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java @@ -4,16 +4,19 @@ import lombok.Data; import makeus.cmc.malmo.domain.model.chat.ChatMessage; import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.type.SenderType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface LoadMessagesPort { - Page loadMessagesDto(ChatRoomId chatRoomId, Pageable pageable); - Page loadMessagesDtoAsc(ChatRoomId chatRoomId, Pageable pageable); + Optional loadMessageById(Long messageId); + Page loadMessagesDto(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable); + Page loadMessagesDtoAsc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable); List loadChatRoomMessagesByLevel(ChatRoomId chatRoomId, int level); List loadChatRoomLevelAndDetailedLevelMessages(ChatRoomId chatRoomId, int level, int detailedLevel); diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java new file mode 100644 index 00000000..be618faa --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java @@ -0,0 +1,8 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import makeus.cmc.malmo.domain.model.chat.Bookmark; + +public interface SaveBookmarkPort { + + Bookmark saveBookmark(Bookmark bookmark); +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java new file mode 100644 index 00000000..9d6a1229 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java @@ -0,0 +1,167 @@ +package makeus.cmc.malmo.application.service.chat; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.in.aop.CheckValidMember; +import makeus.cmc.malmo.application.exception.BookmarkAlreadyExistsException; +import makeus.cmc.malmo.application.exception.MemberAccessDeniedException; +import makeus.cmc.malmo.application.helper.chat_room.BookmarkCommandHelper; +import makeus.cmc.malmo.application.helper.chat_room.BookmarkQueryHelper; +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; +import makeus.cmc.malmo.application.port.in.chat.CreateBookmarkUseCase; +import makeus.cmc.malmo.application.port.in.chat.DeleteBookmarksUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetBookmarkListUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetMessagesByBookmarkUseCase; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookmarkService implements CreateBookmarkUseCase, DeleteBookmarksUseCase, + GetBookmarkListUseCase, GetMessagesByBookmarkUseCase { + + private final BookmarkQueryHelper bookmarkQueryHelper; + private final BookmarkCommandHelper bookmarkCommandHelper; + private final ChatRoomQueryHelper chatRoomQueryHelper; + + @Override + @CheckValidMember + @Transactional + public CreateBookmarkResponse createBookmark(CreateBookmarkCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 1. 채팅방 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + + // 2. 이미 북마크가 존재하는지 확인 + if (bookmarkQueryHelper.existsByMemberAndMessage(memberId, command.getMessageId())) { + throw new BookmarkAlreadyExistsException(); + } + + // 3. 메시지가 해당 채팅방에 존재하는지 검증 + ChatMessage message = bookmarkQueryHelper.validateMessageInChatRoom( + command.getMessageId(), chatRoomId); + + // 4. 북마크 생성 및 저장 + Bookmark bookmark = Bookmark.create(chatRoomId, command.getMessageId(), memberId); + Bookmark savedBookmark = bookmarkCommandHelper.saveBookmark(bookmark); + + return CreateBookmarkResponse.builder() + .bookmarkId(savedBookmark.getId()) + .content(message.getContent()) + .type(message.getSenderType()) + .timestamp(message.getCreatedAt()) + .build(); + } + + @Override + @CheckValidMember + @Transactional + public void deleteBookmarks(DeleteBookmarksCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 1. 채팅방 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + + // 2. 북마크 소유권 검증 + List bookmarkIds = command.getBookmarkIdList().stream() + .map(BookmarkId::of) + .toList(); + bookmarkQueryHelper.validateBookmarksOwnership(memberId, bookmarkIds); + + // 3. 북마크 soft delete + bookmarkCommandHelper.softDeleteBookmarks(bookmarkIds); + } + + @Override + @CheckValidMember + public GetBookmarkListResponse getBookmarkList(GetBookmarkListCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 1. 채팅방 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + + // 2. 페이지네이션된 북마크 조회 + Page bookmarks = bookmarkQueryHelper.getBookmarksByMemberAndChatRoom( + memberId, chatRoomId, command.getPageable()); + + List dtos = bookmarks.getContent().stream() + .map(b -> BookmarkDto.builder() + .bookmarkId(b.getBookmarkId()) + .content(b.getContent()) + .type(b.getSenderType()) + .timestamp(b.getCreatedAt()) + .build()) + .toList(); + + return GetBookmarkListResponse.builder() + .bookmarkList(dtos) + .totalCount(bookmarks.getTotalElements()) + .build(); + } + + @Override + @CheckValidMember + public GetMessagesByBookmarkResponse getMessagesByBookmark(GetMessagesByBookmarkCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + + // 1. 북마크 조회 및 검증 + Bookmark bookmark = bookmarkQueryHelper.getBookmarkByIdOrThrow( + BookmarkId.of(command.getBookmarkId())); + + // 2. 북마크 소유권 검증 + if (!bookmark.getMemberId().getValue().equals(command.getUserId())) { + throw new MemberAccessDeniedException("북마크에 접근할 권한이 없습니다."); + } + + // 3. 북마크된 메시지가 포함된 페이지 계산 + int page = bookmarkQueryHelper.calculatePageForMessage( + bookmark.getChatRoomId(), + bookmark.getChatMessageId(), + command.getSize(), + command.getSort()); + + // 4. 해당 페이지의 메시지 조회 + Sort sort = "ASC".equalsIgnoreCase(command.getSort()) + ? Sort.by("createdAt").ascending() + : Sort.by("createdAt").descending(); + Pageable pageable = PageRequest.of(page, command.getSize(), sort); + + Page messages = + "ASC".equalsIgnoreCase(command.getSort()) + ? chatRoomQueryHelper.getChatMessagesDtoAsc(bookmark.getChatRoomId(), memberId, pageable) + : chatRoomQueryHelper.getChatMessagesDtoDesc(bookmark.getChatRoomId(), memberId, pageable); + + List messageDtos = messages.getContent().stream() + .map(m -> MessageDto.builder() + .messageId(m.getMessageId()) + .content(m.getContent()) + .senderType(m.getSenderType()) + .createdAt(m.getCreatedAt()) + .isSaved(m.isSaved()) + .build()) + .toList(); + + return GetMessagesByBookmarkResponse.builder() + .targetMessageId(bookmark.getChatMessageId()) + .size(command.getSize()) + .page(page) + .messages(messageDtos) + .build(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java index 90fa5995..26427177 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java @@ -77,10 +77,11 @@ public GetChatRoomListResponse getChatRoomList(GetChatRoomListCommand command) { @Override @CheckValidMember public GetCurrentChatRoomMessagesResponse getChatRoomMessages(GetChatRoomMessagesCommand command) { - chatRoomQueryHelper.validateChatRoomOwnership(MemberId.of(command.getUserId()), ChatRoomId.of(command.getChatRoomId())); + MemberId memberId = MemberId.of(command.getUserId()); + chatRoomQueryHelper.validateChatRoomOwnership(memberId, ChatRoomId.of(command.getChatRoomId())); Page result = - chatRoomQueryHelper.getChatMessagesDtoAsc(ChatRoomId.of(command.getChatRoomId()), command.getPageable()); + chatRoomQueryHelper.getChatMessagesDtoAsc(ChatRoomId.of(command.getChatRoomId()), memberId, command.getPageable()); List list = result.stream().map(cm -> GetChatRoomMessagesUseCase.ChatRoomMessageDto.builder() diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java index f17891c2..d4df9b5a 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java @@ -94,10 +94,11 @@ private ChatRoom createAndSaveNewChatRoom(MemberId memberId) { @CheckValidMember public GetCurrentChatRoomMessagesResponse getCurrentChatRoomMessages(GetCurrentChatRoomMessagesCommand command) { // 현재 채팅방 가져오기 - ChatRoom currentChatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(MemberId.of(command.getUserId())); + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoom currentChatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(memberId); Page result = - chatRoomQueryHelper.getChatMessagesDtoDesc(ChatRoomId.of(currentChatRoom.getId()), command.getPageable()); + chatRoomQueryHelper.getChatMessagesDtoDesc(ChatRoomId.of(currentChatRoom.getId()), memberId, command.getPageable()); List list = result.stream().map(cm -> ChatRoomMessageDto.builder() diff --git a/src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java b/src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java new file mode 100644 index 00000000..aa3f61b3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java @@ -0,0 +1,55 @@ +package makeus.cmc.malmo.domain.model.chat; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.state.BookmarkState; + +import java.time.LocalDateTime; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class Bookmark { + private Long id; + private ChatRoomId chatRoomId; + private Long chatMessageId; + private MemberId memberId; + private BookmarkState bookmarkState; + + // BaseTimeEntity fields + private LocalDateTime createdAt; + private LocalDateTime modifiedAt; + private LocalDateTime deletedAt; + + public static Bookmark create(ChatRoomId chatRoomId, Long chatMessageId, MemberId memberId) { + return Bookmark.builder() + .chatRoomId(chatRoomId) + .chatMessageId(chatMessageId) + .memberId(memberId) + .bookmarkState(BookmarkState.ALIVE) + .build(); + } + + public static Bookmark from(Long id, ChatRoomId chatRoomId, Long chatMessageId, + MemberId memberId, BookmarkState bookmarkState, + LocalDateTime createdAt, LocalDateTime modifiedAt, + LocalDateTime deletedAt) { + return Bookmark.builder() + .id(id) + .chatRoomId(chatRoomId) + .chatMessageId(chatMessageId) + .memberId(memberId) + .bookmarkState(bookmarkState) + .createdAt(createdAt) + .modifiedAt(modifiedAt) + .deletedAt(deletedAt) + .build(); + } + + public void softDelete() { + this.bookmarkState = BookmarkState.DELETED; + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java b/src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java new file mode 100644 index 00000000..8fc6239a --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.domain.value.id; + +import lombok.Value; + +@Value +public class BookmarkId { + Long value; + + public static BookmarkId of(Long value) { + return new BookmarkId(value); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java b/src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java new file mode 100644 index 00000000..d645fac2 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java @@ -0,0 +1,6 @@ +package makeus.cmc.malmo.domain.value.state; + +public enum BookmarkState { + ALIVE, + DELETED +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java new file mode 100644 index 00000000..5863866f --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java @@ -0,0 +1,502 @@ +package makeus.cmc.malmo.integration_test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatRoomEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.member.MemberEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatMessageEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatRoomEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.InviteCodeEntityValue; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.MemberEntityId; +import makeus.cmc.malmo.application.port.out.member.GenerateTokenPort; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import makeus.cmc.malmo.domain.value.state.ChatRoomState; +import makeus.cmc.malmo.domain.value.state.MemberState; +import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.Provider; +import makeus.cmc.malmo.domain.value.type.SenderType; +import makeus.cmc.malmo.integration_test.dto_factory.BookmarkRequestDtoFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Transactional +public class BookmarkIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EntityManager em; + + @Autowired + private GenerateTokenPort generateTokenPort; + + private String accessToken; + private String otherAccessToken; + + private MemberEntity member; + private MemberEntity otherMember; + private ChatRoomEntity chatRoom; + private ChatMessageEntity chatMessage; + private ChatMessageEntity chatMessage2; + + @BeforeEach + void setup() { + member = createAndSaveMember("testUser", "test@email.com", "invite1"); + otherMember = createAndSaveMember("otherUser", "other@email.com", "invite2"); + + chatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .level(1) + .detailedLevel(1) + .build(); + em.persist(chatRoom); + + chatMessage = ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("테스트 메시지 1") + .senderType(SenderType.USER) + .level(1) + .detailedLevel(1) + .build(); + em.persist(chatMessage); + + chatMessage2 = ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("테스트 메시지 2") + .senderType(SenderType.ASSISTANT) + .level(1) + .detailedLevel(1) + .build(); + em.persist(chatMessage2); + + em.flush(); + + accessToken = generateTokenPort.generateToken(member.getId(), member.getMemberRole()).getAccessToken(); + otherAccessToken = generateTokenPort.generateToken(otherMember.getId(), otherMember.getMemberRole()).getAccessToken(); + } + + @Nested + @DisplayName("북마크 생성") + class CreateBookmark { + + @Test + @DisplayName("북마크 생성에 성공한다") + void 북마크_생성_성공() throws Exception { + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(chatMessage.getId()); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.bookmarkId").exists()) + .andExpect(jsonPath("$.data.content").value("테스트 메시지 1")) + .andExpect(jsonPath("$.data.type").value("USER")); + } + + @Test + @DisplayName("이미 북마크된 메시지에 대해 북마크 생성에 실패한다") + void 이미_북마크된_메시지_북마크_생성_실패() throws Exception { + // Given: 이미 북마크 존재 + BookmarkEntity existingBookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(existingBookmark); + em.flush(); + + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(chatMessage.getId()); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40015)); // BOOKMARK_ALREADY_EXISTS + } + + @Test + @DisplayName("다른 사용자의 채팅방에 대해 북마크 생성에 실패한다") + void 다른_사용자_채팅방_북마크_생성_실패() throws Exception { + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(chatMessage.getId()); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + otherAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("존재하지 않는 메시지에 대해 북마크 생성에 실패한다") + void 존재하지_않는_메시지_북마크_생성_실패() throws Exception { + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(999999L); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40016)); // NO_SUCH_MESSAGE + } + } + + @Nested + @DisplayName("북마크 삭제") + class DeleteBookmarks { + + @Test + @DisplayName("북마크 단건 삭제에 성공한다") + void 북마크_단건_삭제_성공() throws Exception { + // Given + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + em.clear(); + + BookmarkRequestDtoFactory.DeleteBookmarksRequest request = + BookmarkRequestDtoFactory.deleteBookmarksRequest(List.of(bookmark.getId())); + + mockMvc.perform(delete("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + BookmarkEntity deletedBookmark = em.find(BookmarkEntity.class, bookmark.getId()); + assertThat(deletedBookmark.getBookmarkState()).isEqualTo(BookmarkState.DELETED); + } + + @Test + @DisplayName("북마크 다건 삭제에 성공한다") + void 북마크_다건_삭제_성공() throws Exception { + // Given + BookmarkEntity bookmark1 = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + BookmarkEntity bookmark2 = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage2.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark1); + em.persist(bookmark2); + em.flush(); + em.clear(); + + BookmarkRequestDtoFactory.DeleteBookmarksRequest request = + BookmarkRequestDtoFactory.deleteBookmarksRequest( + List.of(bookmark1.getId(), bookmark2.getId())); + + mockMvc.perform(delete("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + assertThat(em.find(BookmarkEntity.class, bookmark1.getId()).getBookmarkState()) + .isEqualTo(BookmarkState.DELETED); + assertThat(em.find(BookmarkEntity.class, bookmark2.getId()).getBookmarkState()) + .isEqualTo(BookmarkState.DELETED); + } + + @Test + @DisplayName("다른 사용자의 북마크 삭제에 실패한다") + void 다른_사용자_북마크_삭제_실패() throws Exception { + // Given: member's bookmark + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + + BookmarkRequestDtoFactory.DeleteBookmarksRequest request = + BookmarkRequestDtoFactory.deleteBookmarksRequest(List.of(bookmark.getId())); + + // otherMember tries to delete member's bookmark + mockMvc.perform(delete("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + otherAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("북마크 리스트 조회") + class GetBookmarkList { + + @Test + @DisplayName("북마크 리스트 조회에 성공한다") + void 북마크_리스트_조회_성공() throws Exception { + // Given + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage2.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.list.length()").value(2)); + } + + @Test + @DisplayName("삭제된 북마크는 조회되지 않는다") + void 삭제된_북마크_미조회() throws Exception { + // Given + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.DELETED) // 삭제된 상태 + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(0)); + } + + @Test + @DisplayName("페이지네이션이 정상 동작한다") + void 페이지네이션_동작() throws Exception { + // Given: 15개의 북마크 생성 + for (int i = 0; i < 15; i++) { + ChatMessageEntity msg = ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("메시지 " + i) + .senderType(SenderType.USER) + .level(1) + .detailedLevel(1) + .build(); + em.persist(msg); + em.flush(); + + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(msg.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + } + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.list.length()").value(10)); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list.length()").value(5)); + } + + @Test + @DisplayName("다른 사용자의 채팅방 북마크 조회에 실패한다") + void 다른_사용자_채팅방_북마크_조회_실패() throws Exception { + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + otherAccessToken)) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("북마크 기반 메시지 조회") + class GetMessagesByBookmark { + + @Test + @DisplayName("북마크 기반 메시지 조회에 성공한다") + void 북마크_기반_메시지_조회_성공() throws Exception { + // Given + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks/{bookmarkId}/messages", + chatRoom.getId(), bookmark.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("size", "10") + .param("sort", "ASC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.targetMessageId").value(chatMessage.getId())) + .andExpect(jsonPath("$.data.messages").isArray()); + } + + @Test + @DisplayName("다른 사용자의 북마크 기반 메시지 조회에 실패한다") + void 다른_사용자_북마크_기반_메시지_조회_실패() throws Exception { + // Given + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks/{bookmarkId}/messages", + chatRoom.getId(), bookmark.getId()) + .header("Authorization", "Bearer " + otherAccessToken) + .param("size", "10") + .param("sort", "ASC")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("존재하지 않는 북마크 기반 메시지 조회에 실패한다") + void 존재하지_않는_북마크_메시지_조회_실패() throws Exception { + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks/{bookmarkId}/messages", + chatRoom.getId(), 999999L) + .header("Authorization", "Bearer " + accessToken) + .param("size", "10") + .param("sort", "ASC")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40014)); // NO_SUCH_BOOKMARK + } + } + + @Nested + @DisplayName("채팅 메시지 리스트 조회 시 북마크 여부") + class GetChatRoomMessagesWithBookmarkStatus { + + @Test + @DisplayName("북마크된 메시지의 isSaved가 true로 반환된다") + void 북마크된_메시지_isSaved_true() throws Exception { + // Given + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[?(@.messageId == " + chatMessage.getId() + ")].isSaved").value(true)); + } + + @Test + @DisplayName("북마크되지 않은 메시지의 isSaved가 false로 반환된다") + void 북마크되지_않은_메시지_isSaved_false() throws Exception { + mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].isSaved").value(false)); + } + + @Test + @DisplayName("삭제된 북마크는 isSaved가 false로 반환된다") + void 삭제된_북마크_isSaved_false() throws Exception { + // Given: 삭제된 북마크 + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.DELETED) + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].isSaved").value(false)); + } + } + + private MemberEntity createAndSaveMember(String nickname, String email, String inviteCode) { + MemberEntity memberEntity = MemberEntity.builder() + .provider(Provider.KAKAO) + .providerId(email) + .memberRole(MemberRole.MEMBER) + .memberState(MemberState.ALIVE) + .startLoveDate(LocalDate.of(2023, 1, 1)) + .nickname(nickname) + .email(email) + .inviteCodeEntityValue(InviteCodeEntityValue.of(inviteCode)) + .build(); + em.persist(memberEntity); + return memberEntity; + } +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java b/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java new file mode 100644 index 00000000..6a97498c --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java @@ -0,0 +1,18 @@ +package makeus.cmc.malmo.integration_test.dto_factory; + +import java.util.List; + +public class BookmarkRequestDtoFactory { + + public static CreateBookmarkRequest createBookmarkRequest(Long messageId) { + return new CreateBookmarkRequest(messageId); + } + + public static DeleteBookmarksRequest deleteBookmarksRequest(List bookmarkIds) { + return new DeleteBookmarksRequest(bookmarkIds); + } + + public record CreateBookmarkRequest(Long messageId) {} + + public record DeleteBookmarksRequest(List bookmarkIdList) {} +} From e73483f2b16ae9db00caa59a1e93fc2e2f2bd9aa Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:55:42 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20id?= =?UTF-8?q?=EB=A5=BC=20=ED=95=A8=EA=BB=98=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(=EC=9E=84=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java | 3 +++ .../application/port/in/chat/GetCurrentChatRoomUseCase.java | 1 + .../malmo/application/service/chat/CurrentChatRoomService.java | 1 + 3 files changed, 5 insertions(+) diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java index 1095b311..693867d2 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java @@ -540,6 +540,9 @@ public static class SendChatData { @Getter @Schema(description = "채팅방 상태 응답 데이터") public static class ChatRoomStateData { + @Schema(description = "현재 채팅방의 ID", example = "1") + private Long chatRoomId; + @Schema(description = "현재 채팅방의 상태", example = "BEFORE_INIT") private ChatRoomState chatRoomState; } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java index eee306ed..6b064bfc 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java @@ -17,6 +17,7 @@ class GetCurrentChatRoomCommand { @Data @Builder class GetCurrentChatRoomResponse { + private Long chatRoomId; private ChatRoomState chatRoomState; } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java index d4df9b5a..299e0934 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java @@ -69,6 +69,7 @@ public GetCurrentChatRoomResponse getCurrentChatRoom(GetCurrentChatRoomCommand c }); return GetCurrentChatRoomResponse.builder() + .chatRoomId(currentChatRoom.getId()) .chatRoomState(currentChatRoom.getChatRoomState()) .build(); } From c8c3c6124b486cbd656563968b53f86796e2fc45 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:02:31 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=EC=9D=84=20=EB=B6=84=ED=95=A0=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/ChatRoomPersistenceAdapter.java | 14 ++ .../chat_room/ChatRoomCommandHelper.java | 4 + .../port/out/chat/SaveChatMessagePort.java | 4 + .../service/chat/ChatMessageService.java | 23 +- .../application/service/chat/ChatService.java | 24 +- .../service/chat/ChatSseSender.java | 11 + .../cmc/malmo/util/ChatMessageSplitter.java | 106 +++++++++ .../malmo/util/ChatMessageSplitterTest.java | 208 ++++++++++++++++++ 8 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java create mode 100644 src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 4a732e54..2a44a6f5 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -82,6 +82,20 @@ public ChatMessage saveChatMessage(ChatMessage chatMessage) { return chatMessageMapper.toDomain(savedEntity); } + @Override + public List saveChatMessages(List chatMessages) { + if (chatMessages == null || chatMessages.isEmpty()) { + return List.of(); + } + List entities = chatMessages.stream() + .map(chatMessageMapper::toEntity) + .toList(); + List savedEntities = chatMessageRepository.saveAll(entities); + return savedEntities.stream() + .map(chatMessageMapper::toDomain) + .toList(); + } + @Override public Optional loadChatRoomById(ChatRoomId chatRoomId) { return chatRoomRepository.findById(chatRoomId.getValue()) diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java index 06d156c3..accec09c 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java @@ -31,4 +31,8 @@ public void deleteChatRooms(List chatRoomIds) { public ChatMessage saveChatMessage(ChatMessage chatMessage) { return saveChatMessagePort.saveChatMessage(chatMessage); } + + public List saveChatMessages(List chatMessages) { + return saveChatMessagePort.saveChatMessages(chatMessages); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java index bb1fac3e..7a7d2e37 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessagePort.java @@ -2,6 +2,10 @@ import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import java.util.List; + public interface SaveChatMessagePort { ChatMessage saveChatMessage(ChatMessage chatMessage); + + List saveChatMessages(List chatMessages); } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index 232685fb..03463fc6 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -30,11 +30,13 @@ import makeus.cmc.malmo.domain.value.id.CoupleId; import makeus.cmc.malmo.domain.value.id.CoupleQuestionId; import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.util.ChatMessageSplitter; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; @Slf4j @Service @@ -287,8 +289,23 @@ private CompletableFuture requestNextStageOpening(Member member, ChatRoom } private void saveAiMessage(MemberId memberId, ChatRoomId chatRoomId, int level, int detailedLevel, String fullAnswer) { - ChatMessage aiTextMessage = chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, fullAnswer); - ChatMessage savedMessage = chatRoomCommandHelper.saveChatMessage(aiTextMessage); - chatSseSender.sendAiResponseId(memberId, savedMessage.getId()); + // fullAnswer를 문장 단위로 분할하고 세 문장씩 그룹화 + List groupedTexts = ChatMessageSplitter.splitIntoGroups(fullAnswer); + + // 각 그룹을 ChatMessage로 생성 + List chatMessages = groupedTexts.stream() + .map(groupText -> chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, groupText)) + .collect(Collectors.toList()); + + // bulk 저장 + List savedMessages = chatRoomCommandHelper.saveChatMessages(chatMessages); + + // 저장된 메시지들의 ID 리스트 추출 + List messageIds = savedMessages.stream() + .map(ChatMessage::getId) + .collect(Collectors.toList()); + + // SSE로 ID 리스트 전송 + chatSseSender.sendAiResponseIds(memberId, messageIds); } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java index 3e9764ff..b7d73040 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java @@ -19,10 +19,13 @@ import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.state.ChatRoomState; +import makeus.cmc.malmo.util.ChatMessageSplitter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; //import static makeus.cmc.malmo.util.GlobalConstants.FINAL_MESSAGE; @@ -112,8 +115,23 @@ private ChatMessage saveUserMessage(ChatRoom chatRoom, String message) { } private void saveAiMessage(MemberId memberId, ChatRoomId chatRoomId, int level, int detailedLevel, String fullAnswer) { - ChatMessage aiTextMessage = chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, fullAnswer); - ChatMessage savedMessage = chatRoomCommandHelper.saveChatMessage(aiTextMessage); - chatSseSender.sendAiResponseId(memberId, savedMessage.getId()); + // fullAnswer를 문장 단위로 분할하고 세 문장씩 그룹화 + List groupedTexts = ChatMessageSplitter.splitIntoGroups(fullAnswer); + + // 각 그룹을 ChatMessage로 생성 + List chatMessages = groupedTexts.stream() + .map(groupText -> chatRoomDomainService.createAiMessage(chatRoomId, level, detailedLevel, groupText)) + .collect(Collectors.toList()); + + // bulk 저장 + List savedMessages = chatRoomCommandHelper.saveChatMessages(chatMessages); + + // 저장된 메시지들의 ID 리스트 추출 + List messageIds = savedMessages.stream() + .map(ChatMessage::getId) + .collect(Collectors.toList()); + + // SSE로 ID 리스트 전송 + chatSseSender.sendAiResponseIds(memberId, messageIds); } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java index 72f9e4db..fb087400 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatSseSender.java @@ -5,6 +5,8 @@ import makeus.cmc.malmo.domain.value.id.MemberId; import org.springframework.stereotype.Service; +import java.util.List; + @Service @RequiredArgsConstructor public class ChatSseSender { @@ -32,6 +34,15 @@ public void sendAiResponseId(MemberId memberId, Long messageId) { )); } + public void sendAiResponseIds(MemberId memberId, List messageIds) { + sendSseEventPort.sendToMember( + memberId, + new SendSseEventPort.NotificationEvent( + SendSseEventPort.SseEventType.AI_RESPONSE_ID, + messageIds + )); + } + public void sendError(MemberId memberId, String errorMessage) { sendSseEventPort.sendToMember( memberId, diff --git a/src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java b/src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java new file mode 100644 index 00000000..bbaba7ae --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/util/ChatMessageSplitter.java @@ -0,0 +1,106 @@ +package makeus.cmc.malmo.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 채팅 메시지를 문장 단위로 분할하고 그룹화하는 유틸리티 클래스 + */ +public class ChatMessageSplitter { + + /** + * 문장 종결 부호 패턴 (규칙 변경 시 이 값만 수정하면 됨) + */ + private static final Pattern SENTENCE_END_PATTERN = Pattern.compile("[.!?]"); + + /** + * 그룹당 문장 수 (규칙 변경 시 이 값만 수정하면 됨) + */ + private static final int SENTENCES_PER_GROUP = 3; + + /** + * 텍스트를 문장부호 기준으로 문장 단위로 분할합니다. + * 문장부호(. ! ?) 하나만 나와도 문장 종결로 간주합니다. + * + * @param text 분할할 텍스트 + * @return 문장 단위로 분할된 리스트 (문장부호 포함) + */ + public static List splitIntoSentences(String text) { + if (text == null || text.trim().isEmpty()) { + return new ArrayList<>(); + } + + List sentences = new ArrayList<>(); + Matcher matcher = SENTENCE_END_PATTERN.matcher(text); + + int lastIndex = 0; + while (matcher.find()) { + int endIndex = matcher.end(); + String sentence = text.substring(lastIndex, endIndex); + // 첫 번째 문장만 앞뒤 공백 제거, 나머지는 앞 공백 유지 + if (sentences.isEmpty()) { + sentence = sentence.trim(); + } else { + // 앞 공백은 유지하되, 전체가 공백만 있는 경우는 제외 + sentence = sentence.trim().isEmpty() ? sentence : sentence; + } + if (!sentence.trim().isEmpty()) { + sentences.add(sentence); + } + lastIndex = endIndex; + } + + // 마지막 문장부호 이후의 텍스트 처리 + if (lastIndex < text.length()) { + String remaining = text.substring(lastIndex); + if (!remaining.trim().isEmpty()) { + // 첫 번째 문장이 아니면 앞 공백 유지 + if (!sentences.isEmpty()) { + sentences.add(remaining); + } else { + sentences.add(remaining.trim()); + } + } + } + + return sentences; + } + + /** + * 텍스트를 문장 단위로 분할한 후, 세 문장씩 그룹화합니다. + * 예: 10문장인 경우 4개의 그룹으로 생성됩니다. + * + * @param text 그룹화할 텍스트 + * @return 세 문장씩 그룹화된 텍스트 리스트 + */ + public static List splitIntoGroups(String text) { + List sentences = splitIntoSentences(text); + + if (sentences.isEmpty()) { + return new ArrayList<>(); + } + + List groups = new ArrayList<>(); + StringBuilder currentGroup = new StringBuilder(); + + for (int i = 0; i < sentences.size(); i++) { + if (i > 0 && i % SENTENCES_PER_GROUP == 0) { + // 세 문장이 모였으면 그룹 완성 + groups.add(currentGroup.toString()); + currentGroup = new StringBuilder(); + } + + // sentences는 이미 적절한 공백을 포함하고 있으므로 그대로 추가 + currentGroup.append(sentences.get(i)); + } + + // 마지막 남은 문장들 처리 + if (currentGroup.length() > 0) { + groups.add(currentGroup.toString()); + } + + return groups; + } +} diff --git a/src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java b/src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java new file mode 100644 index 00000000..ed0e5e8d --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/util/ChatMessageSplitterTest.java @@ -0,0 +1,208 @@ +package makeus.cmc.malmo.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ChatMessageSplitter 클래스 테스트") +class ChatMessageSplitterTest { + + @Test + @DisplayName("문장부호 기준으로 문장을 분할한다") + void splitIntoSentences_문장부호_기준_분할() { + // given + String text = "첫 번째 문장입니다. 두 번째 문장입니다! 세 번째 문장입니다?"; + + // when + List sentences = ChatMessageSplitter.splitIntoSentences(text); + + // then + assertThat(sentences).hasSize(3); + assertThat(sentences.get(0)).isEqualTo("첫 번째 문장입니다."); + assertThat(sentences.get(1)).isEqualTo(" 두 번째 문장입니다!"); + assertThat(sentences.get(2)).isEqualTo(" 세 번째 문장입니다?"); + } + + @Test + @DisplayName("문장부호 하나만 나와도 문장 종결로 간주한다") + void splitIntoSentences_문장부호_하나만_나와도_종결() { + // given + String text = "문장입니다. 또 다른 문장! 마지막 문장?"; + + // when + List sentences = ChatMessageSplitter.splitIntoSentences(text); + + // then + assertThat(sentences).hasSize(3); + } + + @Test + @DisplayName("세 문장씩 그룹화한다") + void splitIntoGroups_세_문장씩_그룹화() { + // given + String text = "문장1. 문장2! 문장3? 문장4. 문장5! 문장6? 문장7."; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(3); + assertThat(groups.get(0)).isEqualTo("문장1. 문장2! 문장3?"); + assertThat(groups.get(1)).isEqualTo(" 문장4. 문장5! 문장6?"); + assertThat(groups.get(2)).isEqualTo(" 문장7."); + } + + @Test + @DisplayName("10문장인 경우 4개의 그룹으로 생성한다") + void splitIntoGroups_10문장_4개_그룹() { + // given + String text = "1. 2! 3? 4. 5! 6? 7. 8! 9? 10."; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(4); + assertThat(groups.get(0)).isEqualTo("1. 2! 3?"); + assertThat(groups.get(1)).isEqualTo(" 4. 5! 6?"); + assertThat(groups.get(2)).isEqualTo(" 7. 8! 9?"); + assertThat(groups.get(3)).isEqualTo(" 10."); + } + + @Test + @DisplayName("정확히 3의 배수 문장인 경우 그룹화한다") + void splitIntoGroups_3의_배수_문장() { + // given + String text = "문장1. 문장2! 문장3? 문장4. 문장5! 문장6?"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).isEqualTo("문장1. 문장2! 문장3?"); + assertThat(groups.get(1)).isEqualTo(" 문장4. 문장5! 문장6?"); + } + + @Test + @DisplayName("문장이 1개인 경우 1개 그룹으로 생성한다") + void splitIntoGroups_문장_1개() { + // given + String text = "단일 문장입니다."; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).isEqualTo("단일 문장입니다."); + } + + @Test + @DisplayName("문장이 2개인 경우 1개 그룹으로 생성한다") + void splitIntoGroups_문장_2개() { + // given + String text = "첫 번째 문장. 두 번째 문장!"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).isEqualTo("첫 번째 문장. 두 번째 문장!"); + } + + @Test + @DisplayName("문장부호가 없는 경우 전체를 하나의 그룹으로 처리한다") + void splitIntoGroups_문장부호_없음() { + // given + String text = "문장부호가 없는 텍스트입니다"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).isEqualTo("문장부호가 없는 텍스트입니다"); + } + + @Test + @DisplayName("빈 문자열인 경우 빈 리스트를 반환한다") + void splitIntoGroups_빈_문자열() { + // given + String text = ""; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).isEmpty(); + } + + @Test + @DisplayName("공백만 있는 경우 빈 리스트를 반환한다") + void splitIntoGroups_공백만_있음() { + // given + String text = " "; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).isEmpty(); + } + + @Test + @DisplayName("다양한 문장부호 조합을 처리한다") + void splitIntoGroups_다양한_문장부호_조합() { + // given + String text = "문장1. 문장2! 문장3? 문장4. 문장5!"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(2); + assertThat(groups.get(0)).isEqualTo("문장1. 문장2! 문장3?"); + assertThat(groups.get(1)).isEqualTo(" 문장4. 문장5!"); + } + + @Test + @DisplayName("문장부호 뒤에 공백이 없는 경우도 처리한다") + void splitIntoGroups_문장부호_뒤_공백_없음() { + // given + String text = "문장1.문장2!문장3?"; + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(1); + assertThat(groups.get(0)).contains("문장1."); + assertThat(groups.get(0)).contains("문장2!"); + assertThat(groups.get(0)).contains("문장3?"); + } + + @Test + @DisplayName("긴 텍스트도 올바르게 처리한다") + void splitIntoGroups_긴_텍스트() { + // given + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 15; i++) { + sb.append("문장").append(i).append("."); + if (i < 15) { + sb.append(" "); + } + } + String text = sb.toString(); + + // when + List groups = ChatMessageSplitter.splitIntoGroups(text); + + // then + assertThat(groups).hasSize(5); // 15문장 / 3 = 5그룹 + } +} From ab269a52817095401099b07b9040cd0ed7a7c8a6 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Sat, 17 Jan 2026 02:15:54 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix:=20=EC=B4=88=EA=B8=B0=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EB=8F=84=20=EB=B6=84=ED=95=A0=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/chat/CurrentChatRoomService.java | 21 ++++++++++++------- .../ChatRoomIntegrationTest.java | 11 ++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java index 299e0934..89f3e199 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java @@ -19,12 +19,14 @@ import makeus.cmc.malmo.domain.service.ChatRoomDomainService; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.util.ChatMessageSplitter; import makeus.cmc.malmo.util.JosaUtils; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.stream.Collectors; import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHATROOM_LEVEL; import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHAT_MESSAGE; @@ -81,13 +83,18 @@ private ChatRoom createAndSaveNewChatRoom(MemberId memberId) { ChatRoom savedChatRoom = chatRoomCommandHelper.saveChatRoom(chatRoom); // 초기 메시지 생성 및 저장 - ChatMessage initMessage = chatRoomDomainService.createAiMessage( - ChatRoomId.of(savedChatRoom.getId()), - INIT_CHATROOM_LEVEL, - 1, - JosaUtils.아야(member.getNickname()) - + INIT_CHAT_MESSAGE); - chatRoomCommandHelper.saveChatMessage(initMessage); + String initMessageContent = JosaUtils.아야(member.getNickname()) + INIT_CHAT_MESSAGE; + List groupedTexts = ChatMessageSplitter.splitIntoGroups(initMessageContent); + List chatMessages = groupedTexts.stream() + .map(groupText -> chatRoomDomainService.createAiMessage( + ChatRoomId.of(savedChatRoom.getId()), + INIT_CHATROOM_LEVEL, + 1, + groupText)) + .collect(Collectors.toList()); + + chatRoomCommandHelper.saveChatMessages(chatMessages); + return savedChatRoom; } diff --git a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java index 245de2a9..a32ce212 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java @@ -140,8 +140,9 @@ class GetCurrentChatRoom { List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId", ChatMessageEntity.class) .setParameter("chatRoomId", chatRoom.getId()) .getResultList(); - Assertions.assertThat(messages).hasSize(1); - Assertions.assertThat(messages.get(0).getContent()).isEqualTo(member.getNickname() + "아" + INIT_CHAT_MESSAGE); + Assertions.assertThat(messages).hasSize(2); + String contentCombined = messages.get(0).getContent() + messages.get(1).getContent(); + Assertions.assertThat(contentCombined).isEqualTo(member.getNickname() + "아" + INIT_CHAT_MESSAGE); } @Test @@ -201,8 +202,10 @@ class GetCurrentChatRoom { List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId", ChatMessageEntity.class) .setParameter("chatRoomId", newChatRoom.getId()) .getResultList(); - Assertions.assertThat(messages).hasSize(1); - Assertions.assertThat(messages.get(0).getContent()).isEqualTo(member.getNickname() + "아" + INIT_CHAT_MESSAGE); + + Assertions.assertThat(messages).hasSize(2); + String contentCombined = messages.get(0).getContent() + messages.get(1).getContent(); + Assertions.assertThat(contentCombined).isEqualTo(member.getNickname() + "아" + INIT_CHAT_MESSAGE); } @Test From c1f6ca9b7dc327f9f567216ddc747bf61e9354d5 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Wed, 21 Jan 2026 22:31:10 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20messages=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B6=81=EB=A7=88=ED=81=AC=20id=EB=A5=BC=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adaptor/in/web/docs/SwaggerResponses.java | 4 ++-- .../chat/ChatMessageRepositoryCustomImpl.java | 4 ++-- .../in/chat/GetChatRoomMessagesUseCase.java | 4 +--- .../GetCurrentChatRoomMessagesUseCase.java | 4 +--- .../in/chat/GetMessagesByBookmarkUseCase.java | 4 +--- .../port/out/chat/LoadMessagesPort.java | 2 +- .../service/chat/BookmarkService.java | 2 +- .../service/chat/ChatRoomService.java | 2 +- .../service/chat/CurrentChatRoomService.java | 2 +- .../BookmarkIntegrationTest.java | 23 ++++++++++--------- .../ChatRoomIntegrationTest.java | 8 ++++--- 11 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java index 693867d2..6711522d 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java @@ -558,8 +558,8 @@ class ChatRoomMessageData { private String content; @Schema(description = "채팅 생성 시간", example = "2025-07-20T10:15:30") private LocalDateTime createdAt; - @Schema(description = "채팅 저장 여부", example = "true") - private boolean isSaved; + @Schema(description = "해당 메시지에 대한 북마크 ID (없으면 null)", example = "10") + private Long bookmarkId; } @Getter diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java index 04b682c3..680051ea 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java @@ -26,7 +26,7 @@ public Page loadCurrentMessagesDt chatMessageEntity.senderType, chatMessageEntity.content, chatMessageEntity.createdAt, - bookmarkEntity.isNotNull() + bookmarkEntity.id )) .from(chatMessageEntity) .leftJoin(bookmarkEntity) @@ -54,7 +54,7 @@ public Page loadCurrentMessagesDt chatMessageEntity.senderType, chatMessageEntity.content, chatMessageEntity.createdAt, - bookmarkEntity.isNotNull() + bookmarkEntity.id )) .from(chatMessageEntity) .leftJoin(bookmarkEntity) diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomMessagesUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomMessagesUseCase.java index b97ae160..df59b063 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomMessagesUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomMessagesUseCase.java @@ -1,6 +1,5 @@ package makeus.cmc.malmo.application.port.in.chat; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; import makeus.cmc.malmo.domain.value.type.SenderType; @@ -35,7 +34,6 @@ class ChatRoomMessageDto { private SenderType senderType; private String content; private LocalDateTime createdAt; - @JsonProperty("isSaved") - private boolean isSaved; + private Long bookmarkId; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomMessagesUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomMessagesUseCase.java index b99b60c1..4823ce5c 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomMessagesUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomMessagesUseCase.java @@ -1,6 +1,5 @@ package makeus.cmc.malmo.application.port.in.chat; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; import makeus.cmc.malmo.domain.value.type.SenderType; @@ -34,7 +33,6 @@ class ChatRoomMessageDto { private SenderType senderType; private String content; private LocalDateTime createdAt; - @JsonProperty("isSaved") - private boolean isSaved; + private Long bookmarkId; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java index a650ded2..47a37ab0 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java @@ -1,6 +1,5 @@ package makeus.cmc.malmo.application.port.in.chat; -import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Builder; import lombok.Data; import makeus.cmc.malmo.domain.value.type.SenderType; @@ -37,7 +36,6 @@ class MessageDto { private String content; private SenderType senderType; private LocalDateTime createdAt; - @JsonProperty("isSaved") - private boolean isSaved; + private Long bookmarkId; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java index 1fdd91b0..799506f1 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java @@ -28,6 +28,6 @@ class ChatRoomMessageRepositoryDto { private SenderType senderType; private String content; private LocalDateTime createdAt; - private boolean isSaved; + private Long bookmarkId; } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java index 9d6a1229..5ddab665 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java @@ -153,7 +153,7 @@ public GetMessagesByBookmarkResponse getMessagesByBookmark(GetMessagesByBookmark .content(m.getContent()) .senderType(m.getSenderType()) .createdAt(m.getCreatedAt()) - .isSaved(m.isSaved()) + .bookmarkId(m.getBookmarkId()) .build()) .toList(); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java index 26427177..cb2afddb 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java @@ -89,7 +89,7 @@ public GetCurrentChatRoomMessagesResponse getChatRoomMessages(GetChatRoomMessage .senderType(cm.getSenderType()) .content(cm.getContent()) .createdAt(cm.getCreatedAt()) - .isSaved(cm.isSaved()) + .bookmarkId(cm.getBookmarkId()) .build()) .toList(); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java index 89f3e199..d4303c2e 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java @@ -114,7 +114,7 @@ public GetCurrentChatRoomMessagesResponse getCurrentChatRoomMessages(GetCurrentC .senderType(cm.getSenderType()) .content(cm.getContent()) .createdAt(cm.getCreatedAt()) - .isSaved(cm.isSaved()) + .bookmarkId(cm.getBookmarkId()) .build()) .toList(); diff --git a/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java index 5863866f..e42617ed 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java @@ -434,15 +434,16 @@ class GetMessagesByBookmark { class GetChatRoomMessagesWithBookmarkStatus { @Test - @DisplayName("북마크된 메시지의 isSaved가 true로 반환된다") - void 북마크된_메시지_isSaved_true() throws Exception { + @DisplayName("북마크된 메시지의 bookmarkId가 응답에 포함된다") + void 북마크된_메시지_bookmarkId_포함() throws Exception { // Given - em.persist(BookmarkEntity.builder() + BookmarkEntity bookmark = BookmarkEntity.builder() .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) .memberEntityId(MemberEntityId.of(member.getId())) .bookmarkState(BookmarkState.ALIVE) - .build()); + .build(); + em.persist(bookmark); em.flush(); mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) @@ -450,23 +451,23 @@ class GetChatRoomMessagesWithBookmarkStatus { .param("page", "0") .param("size", "10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.list[?(@.messageId == " + chatMessage.getId() + ")].isSaved").value(true)); + .andExpect(jsonPath("$.data.list[0].bookmarkId").value(bookmark.getId())); } @Test - @DisplayName("북마크되지 않은 메시지의 isSaved가 false로 반환된다") - void 북마크되지_않은_메시지_isSaved_false() throws Exception { + @DisplayName("북마크되지 않은 메시지의 bookmarkId는 응답에 포함되지 않는다") + void 북마크되지_않은_메시지_bookmarkId_없음() throws Exception { mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) .header("Authorization", "Bearer " + accessToken) .param("page", "0") .param("size", "10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.list[0].isSaved").value(false)); + .andExpect(jsonPath("$.data.list[0].bookmarkId").doesNotExist()); } @Test - @DisplayName("삭제된 북마크는 isSaved가 false로 반환된다") - void 삭제된_북마크_isSaved_false() throws Exception { + @DisplayName("삭제된 북마크의 메시지 bookmarkId는 응답에 포함되지 않는다") + void 삭제된_북마크_bookmarkId_없음() throws Exception { // Given: 삭제된 북마크 em.persist(BookmarkEntity.builder() .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) @@ -481,7 +482,7 @@ class GetChatRoomMessagesWithBookmarkStatus { .param("page", "0") .param("size", "10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.list[0].isSaved").value(false)); + .andExpect(jsonPath("$.data.list[0].bookmarkId").doesNotExist()); } } diff --git a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java index a32ce212..e40987ce 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java @@ -352,8 +352,8 @@ class SendChatMessage { @DisplayName("현재 채팅방 메시지 조회") class GetCurrentChatRoomMessages { @Test - @DisplayName("현재 채팅방 메시지 조회에 성공한다") - void 현재_채팅방_메시지_조회_성공() throws Exception { + @DisplayName("현재 채팅방 메시지 조회에 성공하고 bookmarkId가 포함되지 않는다") + void 현재_채팅방_메시지_조회_성공_bookmarkId_없음() throws Exception { // given ChatRoomEntity chatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.ALIVE).build(); em.persist(chatRoom); @@ -368,7 +368,9 @@ class GetCurrentChatRoomMessages { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.totalCount").value(2)) .andExpect(jsonPath("$.data.list[0].content").value("메시지2")) // 최신순 - .andExpect(jsonPath("$.data.list[1].content").value("메시지1")); + .andExpect(jsonPath("$.data.list[1].content").value("메시지1")) + .andExpect(jsonPath("$.data.list[0].bookmarkId").doesNotExist()) + .andExpect(jsonPath("$.data.list[1].bookmarkId").doesNotExist()); } @Test From 59ed8206a26b60e022119f2c6aae3b6023bbe8b4 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:43:53 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=ED=99=9C=EC=84=B1=ED=99=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=9D=98=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=EC=9D=B4=20=EC=97=AC=EB=9F=AC=20=EA=B0=9C=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API-CHANGES-MM-168.md | 77 +++ sqls/MM-168.sql | 68 +++ sqls/MM-169.sql | 16 + .../malmo/adaptor/in/RedisStreamConsumer.java | 22 +- .../in/web/controller/ChatRoomController.java | 70 ++- .../web/controller/CurrentChatController.java | 136 ----- .../adaptor/in/web/docs/SwaggerResponses.java | 18 +- ...ava => RequestTitleGenerationMessage.java} | 4 +- .../adaptor/message/StreamMessageType.java | 5 +- .../ChatMessageSummaryPersistenceAdapter.java | 10 +- .../adapter/ChatRoomPersistenceAdapter.java | 16 +- .../adapter/PromptPersistenceAdapter.java | 6 + .../entity/chat/ChatRoomEntity.java | 3 + .../persistence/entity/chat/PromptEntity.java | 2 + .../persistence/mapper/ChatRoomMapper.java | 2 + .../out/persistence/mapper/PromptMapper.java | 2 + .../repository/chat/ChatRoomRepository.java | 10 +- .../chat/ChatRoomRepositoryCustomImpl.java | 19 +- .../repository/chat/PromptRepository.java | 3 + .../helper/chat_room/ChatRoomQueryHelper.java | 33 +- .../chat_room/DetailedPromptQueryHelper.java | 19 +- .../helper/chat_room/PromptQueryHelper.java | 45 +- .../port/in/chat/CompleteChatRoomUseCase.java | 20 - .../port/in/chat/CreateChatRoomUseCase.java | 26 + .../port/in/chat/GetChatRoomListUseCase.java | 8 +- .../in/chat/GetCurrentChatRoomUseCase.java | 23 - .../port/in/chat/ProcessMessageUseCase.java | 16 +- .../port/in/chat/SendChatMessageUseCase.java | 1 + .../port/out/chat/LoadChatRoomPort.java | 14 +- .../port/out/chat/LoadPromptPort.java | 2 + .../out/chat/SaveChatMessageSummaryPort.java | 9 - .../service/chat/ChatMessageService.java | 131 ++--- .../service/chat/ChatProcessor.java | 59 +-- .../service/chat/ChatPromptBuilder.java | 119 +---- .../chat/ChatRoomManagementService.java | 58 +++ .../service/chat/ChatRoomService.java | 11 +- .../application/service/chat/ChatService.java | 42 +- .../service/chat/CurrentChatRoomService.java | 149 ------ .../cmc/malmo/domain/model/chat/ChatRoom.java | 52 +- .../cmc/malmo/domain/model/chat/Prompt.java | 3 + .../domain/service/ChatRoomDomainService.java | 10 - .../value/state/ChatRoomCompletedReason.java | 2 +- .../domain/value/state/ChatRoomState.java | 4 +- .../cmc/malmo/util/GlobalConstants.java | 4 - src/main/resources/data-test.sql | 16 +- .../ChatRoomIntegrationTest.java | 483 ++++++++---------- .../ChatRoomRequestDtoFactory.java | 5 +- .../cmc/malmo/mapper/ChatRoomMapperTest.java | 4 + .../cmc/malmo/mapper/PromptMapperTest.java | 4 + 49 files changed, 851 insertions(+), 1010 deletions(-) create mode 100644 docs/API-CHANGES-MM-168.md create mode 100644 sqls/MM-168.sql create mode 100644 sqls/MM-169.sql delete mode 100644 src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CurrentChatController.java rename src/main/java/makeus/cmc/malmo/adaptor/message/{RequestTotalSummaryMessage.java => RequestTitleGenerationMessage.java} (69%) delete mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/CompleteChatRoomUseCase.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateChatRoomUseCase.java delete mode 100644 src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java delete mode 100644 src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java create mode 100644 src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java delete mode 100644 src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java diff --git a/docs/API-CHANGES-MM-168.md b/docs/API-CHANGES-MM-168.md new file mode 100644 index 00000000..746a6186 --- /dev/null +++ b/docs/API-CHANGES-MM-168.md @@ -0,0 +1,77 @@ +# API 변경 사항 (MM-168) + +## 삭제된 API + +### 1. GET /chatrooms/current +변경 전: 현재 채팅방 상태 조회 +변경 후: 삭제됨 +- 더 이상 "현재 채팅방" 개념이 없음. 채팅방 목록에서 상태 확인 가능. + +### 2. GET /chatrooms/current/messages +변경 전: 현재 채팅방의 메시지 조회 +변경 후: 삭제됨 +- `GET /chatrooms/{chatRoomId}/messages` 사용 + +### 3. POST /chatrooms/current/send +변경 전: 현재 채팅방에 메시지 전송 +변경 후: 삭제됨 +- `POST /chatrooms/{chatRoomId}/messages` 사용 + +### 4. POST /chatrooms/current/complete +변경 전: 현재 채팅방 종료 +변경 후: 삭제됨 +- 채팅방 종료는 서버에서 자동 처리됨 + +--- + +## 신규 API + +### 5. POST /chatrooms +변경 전: 없음 +변경 후: 새로운 채팅방 생성 +- 채팅 시작 전 반드시 채팅방을 먼저 생성해야 함 +- 응답으로 `chatRoomId` 반환 + +### 6. POST /chatrooms/{chatRoomId}/messages +변경 전: 없음 +변경 후: 특정 채팅방에 메시지 전송 +- Request Body: `{ "message": "string" }` +- 기존 `/chatrooms/current/send` 대체 + +--- + +## 변경된 API + +### 7. GET /chatrooms +변경 전: +```json +{ + "chatRoomId": 1, + "totalSummary": "전체 요약", + "situationKeyword": "상황 키워드", + "solutionKeyword": "해결 키워드", + "createdAt": "2026-01-12T10:30:00" +} +``` +변경 후: +```json +{ + "chatRoomId": 1, + "title": "채팅방 제목", + "chatRoomState": "ALIVE", + "level": 1, + "lastMessageSentTime": "2026-01-12T10:35:00", + "createdAt": "2026-01-12T10:30:00" +} +``` +- `totalSummary`, `situationKeyword`, `solutionKeyword` 제거 +- `title`, `chatRoomState`, `level`, `lastMessageSentTime` 추가 + +--- + +## Enum 변경 + +### ChatRoomState +변경 전: `BEFORE_INIT`, `ALIVE`, `PAUSED`, `NEED_NEXT_QUESTION`, `COMPLETED`, `DELETED` +변경 후: `ALIVE`, `COMPLETED`, `DELETED` +- 상태가 3개로 단순화됨 diff --git a/sqls/MM-168.sql b/sqls/MM-168.sql new file mode 100644 index 00000000..91fc82ea --- /dev/null +++ b/sqls/MM-168.sql @@ -0,0 +1,68 @@ +-- 작업 01: ChatRoomState enum 정리 마이그레이션 +-- BEFORE_INIT, PAUSED, NEED_NEXT_QUESTION 상태를 ALIVE로 변경 +-- COMPLETED와 DELETED 상태는 그대로 유지 + +-- BEFORE_INIT 상태를 ALIVE로 변경 +UPDATE chat_room +SET chat_room_state = 'ALIVE' +WHERE chat_room_state = 'BEFORE_INIT'; + +-- PAUSED 상태를 ALIVE로 변경 +UPDATE chat_room +SET chat_room_state = 'ALIVE' +WHERE chat_room_state = 'PAUSED'; + +-- NEED_NEXT_QUESTION 상태를 ALIVE로 변경 +UPDATE chat_room +SET chat_room_state = 'ALIVE' +WHERE chat_room_state = 'NEED_NEXT_QUESTION'; + +-- 마이그레이션 확인 쿼리 (실행 후 확인용) +-- SELECT chat_room_state, COUNT(*) as count +-- FROM chat_room +-- GROUP BY chat_room_state; + +-- 작업 02: ChatRoom 도메인 모델 리팩토링 마이그레이션 +-- title 컬럼 추가 (채팅방 제목) + +-- title 컬럼 추가 +ALTER TABLE chat_room ADD COLUMN title VARCHAR(255); + +-- 기존 데이터는 title이 null이므로 별도 업데이트 불필요 +-- 새 채팅방은 1단계 종료 후 title이 생성됨 + +-- 작업 06: 제목 생성 기능을 위한 마이그레이션 +-- prompt 테이블에 is_for_title_generation 컬럼 추가 + +-- is_for_title_generation 컬럼 추가 +ALTER TABLE prompt ADD COLUMN is_for_title_generation BOOLEAN DEFAULT FALSE; + +-- 제목 생성 프롬프트 데이터 추가 +INSERT INTO prompt (level, content, is_for_system, is_for_summary, is_for_completed_response, + is_for_total_summary, is_for_guideline, is_for_answer_metadata, is_for_title_generation) +VALUES (0, '다음 대화 내용을 바탕으로 20자 이내의 간결한 제목을 생성해주세요. +제목은 사용자의 고민을 잘 반영해야 합니다. +제목만 출력하고 따옴표나 부가 설명은 포함하지 마세요.', + false, false, false, false, false, false, true); + +-- 기존 채팅방의 totalSummary를 title로 마이그레이션 +-- COMPLETED 상태이고 totalSummary가 있는 채팅방의 경우 +-- totalSummary의 앞부분을 title로 복사 (최대 255자) +UPDATE chat_room +SET title = CASE + WHEN LENGTH(total_summary) > 50 THEN CONCAT(SUBSTRING(total_summary, 1, 47), '...') + ELSE total_summary +END +WHERE chat_room_state = 'COMPLETED' + AND total_summary IS NOT NULL + AND title IS NULL; + +-- 마이그레이션 확인 쿼리 (실행 후 확인용) +-- SELECT chat_room_id, chat_room_state, +-- LENGTH(total_summary) as summary_length, +-- LENGTH(title) as title_length, +-- title +-- FROM chat_room +-- WHERE chat_room_state = 'COMPLETED' +-- ORDER BY chat_room_id DESC +-- LIMIT 10; diff --git a/sqls/MM-169.sql b/sqls/MM-169.sql new file mode 100644 index 00000000..a6e9a583 --- /dev/null +++ b/sqls/MM-169.sql @@ -0,0 +1,16 @@ +CREATE TABLE bookmark_entity +( + bookmark_id BIGINT NOT NULL, + created_at datetime NULL, + modified_at datetime NULL, + deleted_at datetime NULL, + bookmark_state VARCHAR(255) NULL, + chat_room_id BIGINT NULL, + chat_message_id BIGINT NULL, + member_id BIGINT NOT NULL, + CONSTRAINT pk_bookmark_entity PRIMARY KEY (bookmark_id) +); + +CREATE INDEX idx_bookmark_member_chatroom ON bookmark_entity (member_id, chat_room_id); + +CREATE INDEX idx_bookmark_member_message ON bookmark_entity (member_id, chat_message_id); \ No newline at end of file diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java b/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java index 93be6b04..e51ee153 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java @@ -64,12 +64,12 @@ public void onMessage(MapRecord record) { case REQUEST_CHAT_MESSAGE: future = processChatMessage(payloadNode); break; - case REQUEST_TOTAL_SUMMARY: - future = processTotalSummary(payloadNode); - break; case REQUEST_EXTRACT_METADATA: future = processMetadata(payloadNode); break; + case REQUEST_TITLE_GENERATION: + future = processTitleGeneration(payloadNode); + break; default: log.warn("Unknown message type: {}", type); // 알 수 없는 타입은 바로 ACK 처리 @@ -110,14 +110,6 @@ private CompletableFuture processChatMessage(JsonNode payloadNode) { ); } - private CompletableFuture processTotalSummary(JsonNode payloadNode) { - return processMessageUseCase.processTotalSummary( - ProcessMessageUseCase.ProcessTotalSummaryCommand.builder() - .chatRoomId(payloadNode.get("chatRoomId").asLong()) - .build() - ); - } - private CompletableFuture processMetadata(JsonNode payloadNode) { return processMessageUseCase.processAnswerMetadata( ProcessMessageUseCase.ProcessAnswerCommand.builder() @@ -128,6 +120,14 @@ private CompletableFuture processMetadata(JsonNode payloadNode) { ); } + private CompletableFuture processTitleGeneration(JsonNode payloadNode) { + return processMessageUseCase.processTitleGeneration( + ProcessMessageUseCase.ProcessTitleGenerationCommand.builder() + .chatRoomId(payloadNode.get("chatRoomId").asLong()) + .build() + ); + } + private void handleFailedMessage(MapRecord record) { try { // 현재 retry 횟수 확인 diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/ChatRoomController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/ChatRoomController.java index bee8ccee..2774277f 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/ChatRoomController.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/ChatRoomController.java @@ -6,18 +6,23 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import makeus.cmc.malmo.adaptor.in.web.docs.ApiCommonResponses; import makeus.cmc.malmo.adaptor.in.web.docs.SwaggerResponses; import makeus.cmc.malmo.adaptor.in.web.dto.BaseListResponse; import makeus.cmc.malmo.adaptor.in.web.dto.BaseResponse; +import makeus.cmc.malmo.application.port.in.chat.CreateChatRoomUseCase; import makeus.cmc.malmo.application.port.in.chat.DeleteChatRoomUseCase; import makeus.cmc.malmo.application.port.in.chat.GetChatRoomListUseCase; import makeus.cmc.malmo.application.port.in.chat.GetChatRoomMessagesUseCase; import makeus.cmc.malmo.application.port.in.chat.GetChatRoomSummaryUseCase; +import makeus.cmc.malmo.application.port.in.chat.SendChatMessageUseCase; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -32,10 +37,36 @@ @RequiredArgsConstructor public class ChatRoomController { + private final CreateChatRoomUseCase createChatRoomUseCase; private final GetChatRoomSummaryUseCase getChatRoomSummaryUseCase; private final GetChatRoomListUseCase getChatRoomListUseCase; private final GetChatRoomMessagesUseCase getChatRoomMessagesUseCase; private final DeleteChatRoomUseCase deleteChatRoomUseCase; + private final SendChatMessageUseCase sendChatMessageUseCase; + + @Operation( + summary = "채팅방 생성", + description = "새로운 채팅방을 생성합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "채팅방 생성 성공", + content = @Content(schema = @Schema(implementation = SwaggerResponses.CreateChatRoomResponse.class)) + ) + @ApiCommonResponses.RequireAuth + @PostMapping + public BaseResponse createChatRoom( + @AuthenticationPrincipal User user) { + + CreateChatRoomUseCase.CreateChatRoomResponse response = createChatRoomUseCase.createChatRoom( + CreateChatRoomUseCase.CreateChatRoomCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .build() + ); + + return BaseResponse.success(response); + } @Operation( summary = "채팅방 요약 조회", @@ -113,6 +144,35 @@ public BaseResponse sendMessage( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @Valid @RequestBody SendMessageRequest request) { + + SendChatMessageUseCase.SendChatMessageResponse response = sendChatMessageUseCase.processUserMessage( + SendChatMessageUseCase.SendChatMessageCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .message(request.getMessage()) + .build() + ); + + return BaseResponse.success(response); + } + @Operation( summary = "채팅방 삭제", description = "채팅방을 id 리스트를 통해 다건 동시 삭제합니다. JWT 토큰이 필요합니다.", @@ -126,7 +186,7 @@ public BaseResponse deleteChatRooms( @AuthenticationPrincipal User user, @RequestBody DeleteChatRoomRequestDto requestDto) { DeleteChatRoomUseCase.DeleteChatRoomsCommand command = DeleteChatRoomUseCase.DeleteChatRoomsCommand.builder() .userId(Long.valueOf(user.getUsername())) @@ -137,6 +197,14 @@ public BaseResponse deleteChatRooms( return BaseResponse.success(null); } + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class SendMessageRequest { + @NotBlank(message = "메시지는 비어있을 수 없습니다.") + private String message; + } + @Data @AllArgsConstructor @NoArgsConstructor diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CurrentChatController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CurrentChatController.java deleted file mode 100644 index 7cc37d3c..00000000 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/CurrentChatController.java +++ /dev/null @@ -1,136 +0,0 @@ -package makeus.cmc.malmo.adaptor.in.web.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; -import makeus.cmc.malmo.adaptor.in.web.docs.ApiCommonResponses; -import makeus.cmc.malmo.adaptor.in.web.docs.SwaggerResponses; -import makeus.cmc.malmo.adaptor.in.web.dto.BaseListResponse; -import makeus.cmc.malmo.adaptor.in.web.dto.BaseResponse; -import makeus.cmc.malmo.application.port.in.chat.CompleteChatRoomUseCase; -import makeus.cmc.malmo.application.port.in.chat.GetCurrentChatRoomMessagesUseCase; -import makeus.cmc.malmo.application.port.in.chat.GetCurrentChatRoomUseCase; -import makeus.cmc.malmo.application.port.in.chat.SendChatMessageUseCase; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.User; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "채팅 API", description = "사용자 채팅 전송 및 현재 채팅방을 위한 API") -@RestController -@RequestMapping("/chatrooms/current") -@RequiredArgsConstructor -public class CurrentChatController { - - private final SendChatMessageUseCase sendChatMessageUseCase; - private final GetCurrentChatRoomUseCase getCurrentChatRoomUseCase; - private final GetCurrentChatRoomMessagesUseCase getCurrentChatRoomMessagesUseCase; - private final CompleteChatRoomUseCase completeChatRoomUseCase; - - @Operation( - summary = "채팅방 상태 조회", - description = "현재 채팅방의 상태를 조회합니다. JWT 토큰이 필요합니다.", - security = @SecurityRequirement(name = "Bearer Authentication") - ) - @ApiResponse( - responseCode = "200", - description = "채팅방 상태 조회 성공", - content = @Content(schema = @Schema(implementation = SwaggerResponses.ChatRoomStateResponse.class)) - ) - @ApiCommonResponses.RequireAuth - @GetMapping - public BaseResponse getCurrentChatRoom( - @AuthenticationPrincipal User user) { - GetCurrentChatRoomUseCase.GetCurrentChatRoomCommand command = GetCurrentChatRoomUseCase.GetCurrentChatRoomCommand.builder() - .userId(Long.valueOf(user.getUsername())) - .build(); - return BaseResponse.success(getCurrentChatRoomUseCase.getCurrentChatRoom(command)); - } - - @Operation( - summary = "현재 채팅방 메시지 조회", - description = "현재 채팅방의 메시지를 페이지네이션으로 조회합니다. JWT 토큰이 필요합니다.", - security = @SecurityRequirement(name = "Bearer Authentication") - ) - @ApiResponse( - responseCode = "200", - description = "채팅방 상태 조회 성공", - content = @Content(schema = @Schema(implementation = SwaggerResponses.ChatMessageListSuccessResponse.class)) - ) - @ApiCommonResponses.RequireAuth - @GetMapping("/messages") - public BaseResponse> getCurrentChatRoomMessages( - @PageableDefault(page = 0, size = 10) Pageable pageable, - @AuthenticationPrincipal User user) { - GetCurrentChatRoomMessagesUseCase.GetCurrentChatRoomMessagesCommand command = GetCurrentChatRoomMessagesUseCase.GetCurrentChatRoomMessagesCommand.builder() - .userId(Long.valueOf(user.getUsername())) - .pageable(pageable) - .build(); - GetCurrentChatRoomMessagesUseCase.GetCurrentChatRoomMessagesResponse currentChatRoomMessages = getCurrentChatRoomMessagesUseCase.getCurrentChatRoomMessages(command); - - return BaseListResponse.success(currentChatRoomMessages.getMessages(), currentChatRoomMessages.getTotalCount()); - } - - @Operation( - summary = "채팅 메시지 전송", - description = "서버로 AI 상담을 위한 사용자의 메시지를 전달합니다. AI 응답은 SSE로 전달됩니다. JWT 토큰이 필요합니다.", - security = @SecurityRequirement(name = "Bearer Authentication") - ) - @ApiResponse( - responseCode = "200", - description = "메시지 전송 성공", - content = @Content(schema = @Schema(implementation = SwaggerResponses.SendChatSuccessResponse.class)) - ) - @ApiCommonResponses.RequireAuth - @PostMapping("/send") - public BaseResponse sendChatMessage( - @AuthenticationPrincipal User user, - @Valid @RequestBody ChatRequest request) { - SendChatMessageUseCase.SendChatMessageResponse sendChatMessageResponse = sendChatMessageUseCase.processUserMessage( - SendChatMessageUseCase.SendChatMessageCommand.builder() - .userId(Long.valueOf(user.getUsername())) - .message(request.getMessage()) - .build()); - - return BaseResponse.success(sendChatMessageResponse); - } - - @Operation( - summary = "채팅방 종료", - description = "현재 채팅방을 종료합니다. JWT 토큰이 필요합니다.", - security = @SecurityRequirement(name = "Bearer Authentication") - ) - @ApiResponse( - responseCode = "200", - description = "채팅방 종료 성공; 데이터 응답 값은 없음", - content = @Content(schema = @Schema(implementation = SwaggerResponses.CompleteChatRoomResponse.class)) - ) - @ApiCommonResponses.RequireAuth - @PostMapping("/complete") - public BaseResponse completeChatRoom( - @AuthenticationPrincipal User user) { - CompleteChatRoomUseCase.CompleteChatRoomCommand command = CompleteChatRoomUseCase.CompleteChatRoomCommand.builder() - .userId(Long.valueOf(user.getUsername())) - .build(); - - return BaseResponse.success(completeChatRoomUseCase.completeChatRoom(command)); - } - - @Getter - @AllArgsConstructor - @NoArgsConstructor - public static class ChatRequest { - @NotBlank(message = "메시지는 비어있을 수 없습니다.") - private String message; - } -} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java index 6711522d..5a638a91 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/docs/SwaggerResponses.java @@ -537,13 +537,29 @@ public static class SendChatData { private Long messageId; } + @Getter + @Schema(description = "채팅방 생성 성공 응답") + public static class CreateChatRoomResponse extends BaseSwaggerResponse { + } + + @Getter + @Schema(description = "채팅방 생성 응답 데이터") + public static class CreateChatRoomData { + @Schema(description = "채팅방 ID", example = "123") + private Long chatRoomId; + @Schema(description = "채팅방 상태", example = "ALIVE") + private ChatRoomState chatRoomState; + @Schema(description = "생성 시간", example = "2026-01-12T15:30:00") + private LocalDateTime createdAt; + } + @Getter @Schema(description = "채팅방 상태 응답 데이터") public static class ChatRoomStateData { @Schema(description = "현재 채팅방의 ID", example = "1") private Long chatRoomId; - @Schema(description = "현재 채팅방의 상태", example = "BEFORE_INIT") + @Schema(description = "현재 채팅방의 상태", example = "ALIVE") private ChatRoomState chatRoomState; } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/message/RequestTotalSummaryMessage.java b/src/main/java/makeus/cmc/malmo/adaptor/message/RequestTitleGenerationMessage.java similarity index 69% rename from src/main/java/makeus/cmc/malmo/adaptor/message/RequestTotalSummaryMessage.java rename to src/main/java/makeus/cmc/malmo/adaptor/message/RequestTitleGenerationMessage.java index a9cfa89b..ebe07978 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/message/RequestTotalSummaryMessage.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/message/RequestTitleGenerationMessage.java @@ -3,10 +3,12 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@NoArgsConstructor @AllArgsConstructor @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) -public class RequestTotalSummaryMessage implements StreamMessage { +public class RequestTitleGenerationMessage implements StreamMessage { private Long chatRoomId; } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java b/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java index 046fe7b6..4d403f8c 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java @@ -2,7 +2,6 @@ public enum StreamMessageType { REQUEST_CHAT_MESSAGE, - REQUEST_TOTAL_SUMMARY, - REQUEST_SUMMARY, - REQUEST_EXTRACT_METADATA; + REQUEST_EXTRACT_METADATA, + REQUEST_TITLE_GENERATION; // 제목 생성 요청 } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java index 20e0e39c..2b7e4c81 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java @@ -1,11 +1,9 @@ package makeus.cmc.malmo.adaptor.out.persistence.adapter; import lombok.RequiredArgsConstructor; -import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageSummaryEntity; import makeus.cmc.malmo.adaptor.out.persistence.mapper.ChatMessageSummaryMapper; import makeus.cmc.malmo.adaptor.out.persistence.repository.chat.ChatMessageSummaryRepository; import makeus.cmc.malmo.application.port.out.chat.LoadSummarizedMessages; -import makeus.cmc.malmo.application.port.out.chat.SaveChatMessageSummaryPort; import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import org.springframework.stereotype.Component; @@ -15,7 +13,7 @@ @RequiredArgsConstructor @Component public class ChatMessageSummaryPersistenceAdapter - implements LoadSummarizedMessages, SaveChatMessageSummaryPort { + implements LoadSummarizedMessages { private final ChatMessageSummaryRepository chatMessageSummaryRepository; private final ChatMessageSummaryMapper chatMessageSummaryMapper; @@ -27,10 +25,4 @@ public List loadSummarizedMessages(ChatRoomId chatRoomId) { .map(chatMessageSummaryMapper::toDomain) .toList(); } - - @Override - public void saveChatMessageSummary(ChatMessageSummary chatMessageSummary) { - ChatMessageSummaryEntity entity = chatMessageSummaryMapper.toEntity(chatMessageSummary); - chatMessageSummaryRepository.save(entity); - } } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 2a44a6f5..77377a1b 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -63,9 +63,11 @@ public List loadChatRoomLevelAndDetailedLevelMessages(ChatRoomId ch } @Override - public Optional loadCurrentChatRoomByMemberId(MemberId memberId) { - return chatRoomRepository.findCurrentChatRoomByMemberEntityId(memberId.getValue()) - .map(chatRoomMapper::toDomain); + public List loadActiveChatRoomsByMemberId(MemberId memberId) { + return chatRoomRepository.findActiveChatRoomsByMemberEntityId(memberId.getValue()) + .stream() + .map(chatRoomMapper::toDomain) + .toList(); } @Override @@ -103,13 +105,7 @@ public Optional loadChatRoomById(ChatRoomId chatRoomId) { } @Override - public Optional loadPausedChatRoomByMemberId(MemberId memberId) { - return chatRoomRepository.findPausedChatRoomByMemberEntityId(memberId.getValue()) - .map(chatRoomMapper::toDomain); - } - - @Override - public Page loadAliveChatRoomsByMemberId(MemberId memberId, String keyword, Pageable pageable) { + public Page loadChatRoomsByMemberId(MemberId memberId, String keyword, Pageable pageable) { Page chatRoomEntities = chatRoomRepository.loadChatRoomListByMemberId(memberId.getValue(), keyword, pageable); return new PageImpl<>(chatRoomEntities.stream().map(chatRoomMapper::toDomain).toList(), pageable, diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/PromptPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/PromptPersistenceAdapter.java index bc3de086..24a95ec7 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/PromptPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/PromptPersistenceAdapter.java @@ -64,4 +64,10 @@ public Optional loadSummaryPromptByLevel(int level) { return promptRepository.findByLevelAndIsForSummaryTrue(level) .map(promptMapper::toDomain); } + + @Override + public Optional loadTitleGenerationPrompt() { + return promptRepository.findByIsForTitleGenerationTrue() + .map(promptMapper::toDomain); + } } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/ChatRoomEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/ChatRoomEntity.java index d2a4261e..cfee9822 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/ChatRoomEntity.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/ChatRoomEntity.java @@ -35,6 +35,9 @@ public class ChatRoomEntity extends BaseTimeEntity { private LocalDateTime lastMessageSentTime; + @Column(length = 255) + private String title; + @Column(columnDefinition = "TEXT") private String totalSummary; diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/PromptEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/PromptEntity.java index df0fec95..56fe93ff 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/PromptEntity.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/PromptEntity.java @@ -35,4 +35,6 @@ public class PromptEntity extends BaseTimeEntity { private boolean isForAnswerMetadata; + private boolean isForTitleGeneration; + } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/ChatRoomMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/ChatRoomMapper.java index 4b9ae8ce..a47d40aa 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/ChatRoomMapper.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/ChatRoomMapper.java @@ -19,6 +19,7 @@ public ChatRoom toDomain(ChatRoomEntity entity) { entity.getLevel(), entity.getDetailedLevel(), entity.getLastMessageSentTime(), + entity.getTitle(), entity.getTotalSummary(), entity.getSituationKeyword(), entity.getSolutionKeyword(), @@ -42,6 +43,7 @@ public ChatRoomEntity toEntity(ChatRoom domain) { .lastMessageSentTime(domain.getLastMessageSentTime()) .level(domain.getLevel()) .detailedLevel(domain.getDetailedLevel()) + .title(domain.getTitle()) .totalSummary(domain.getTotalSummary()) .situationKeyword(domain.getSituationKeyword()) .solutionKeyword(domain.getSolutionKeyword()) diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/PromptMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/PromptMapper.java index 1a69c5ed..3a342424 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/PromptMapper.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/PromptMapper.java @@ -22,6 +22,7 @@ public Prompt toDomain(PromptEntity entity) { entity.isForTotalSummary(), entity.isForGuideline(), entity.isForAnswerMetadata(), + entity.isForTitleGeneration(), entity.getCreatedAt(), entity.getModifiedAt(), entity.getDeletedAt() @@ -43,6 +44,7 @@ public PromptEntity toEntity(Prompt domain) { .isForTotalSummary(domain.isForTotalSummary()) .isForGuideline(domain.isForGuideline()) .isForAnswerMetadata(domain.isForAnswerMetadata()) + .isForTitleGeneration(domain.isForTitleGeneration()) .createdAt(domain.getCreatedAt()) .modifiedAt(domain.getModifiedAt()) .deletedAt(domain.getDeletedAt()) diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java index 314055fc..9a063af1 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java @@ -4,13 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import java.util.Optional; +import java.util.List; public interface ChatRoomRepository extends JpaRepository, ChatRoomRepositoryCustom { - @Query("select c from ChatRoomEntity c where c.memberEntityId.value = ?1 AND c.chatRoomState != 'DELETED' AND c.chatRoomState != 'COMPLETED'") - Optional findCurrentChatRoomByMemberEntityId(Long memberId); - - @Query("select c from ChatRoomEntity c where c.memberEntityId.value = ?1 AND c.chatRoomState = 'PAUSED'") - Optional findPausedChatRoomByMemberEntityId(Long memberId); + // 진행 중인 채팅방 목록 조회 (ALIVE 상태만) + @Query("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = ?1 AND c.chatRoomState = 'ALIVE' ORDER BY c.lastMessageSentTime DESC") + List findActiveChatRoomsByMemberEntityId(Long memberId); } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java index 30e5fa42..2febcb9e 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java @@ -19,24 +19,25 @@ public class ChatRoomRepositoryCustomImpl implements ChatRoomRepositoryCustom{ private final JPAQueryFactory queryFactory; public Page loadChatRoomListByMemberId(Long memberId, String keyword, Pageable pageable) { - BooleanExpression keywordCondition = keyword == null || keyword.isEmpty() + // DELETED만 제외하고 조회 + BooleanExpression baseCondition = chatRoomEntity.memberEntityId.value.eq(memberId) + .and(chatRoomEntity.chatRoomState.ne(ChatRoomState.DELETED)); + + // 키워드 검색 (제목 기준) + BooleanExpression keywordCondition = keyword == null || keyword.isBlank() ? null - : chatRoomEntity.totalSummary.containsIgnoreCase(keyword); + : chatRoomEntity.title.containsIgnoreCase(keyword); List content = queryFactory.selectFrom(chatRoomEntity) - .where(chatRoomEntity.memberEntityId.value.eq(memberId) - .and(chatRoomEntity.chatRoomState.eq(ChatRoomState.COMPLETED)) - .and(keywordCondition)) - .orderBy(chatRoomEntity.createdAt.desc()) + .where(baseCondition.and(keywordCondition)) + .orderBy(chatRoomEntity.lastMessageSentTime.desc()) // 최근 활동 순 .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); long total = queryFactory.select(chatRoomEntity.count()) .from(chatRoomEntity) - .where(chatRoomEntity.memberEntityId.value.eq(memberId) - .and(chatRoomEntity.chatRoomState.eq(ChatRoomState.COMPLETED)) - .and(keywordCondition)) + .where(baseCondition.and(keywordCondition)) .fetchOne(); return new PageImpl<>(content, pageable, total); diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/PromptRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/PromptRepository.java index 02fa0fb9..dbb24d08 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/PromptRepository.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/PromptRepository.java @@ -32,4 +32,7 @@ public interface PromptRepository extends JpaRepository { @Query("select p from PromptEntity p where p.level = ?1 and p.isForSummary = true") Optional findByLevelAndIsForSummaryTrue(int level); + + @Query("SELECT p FROM PromptEntity p WHERE p.isForTitleGeneration = true") + Optional findByIsForTitleGenerationTrue(); } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java index 6106a126..ba2a8e50 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java @@ -35,13 +35,9 @@ public class ChatRoomQueryHelper { private final LoadMessagesPort loadMessagesPort; private final LoadSummarizedMessages loadSummarizedMessages; - public Optional getCurrentChatRoomByMemberId(MemberId memberId) { - return loadChatRoomPort.loadCurrentChatRoomByMemberId(memberId); - } - - public ChatRoom getCurrentChatRoomByMemberIdOrThrow(MemberId memberId) { - return loadChatRoomPort.loadCurrentChatRoomByMemberId(memberId) - .orElseThrow(ChatRoomNotFoundException::new); + // 진행 중인 채팅방 목록 조회 + public List getActiveChatRoomsByMemberId(MemberId memberId) { + return loadChatRoomPort.loadActiveChatRoomsByMemberId(memberId); } public LoadChatRoomMetadataPort.ChatRoomMetadataDto getChatRoomMetadata(MemberId memberId) { @@ -54,8 +50,8 @@ public ChatRoom getChatRoomByIdOrThrow(ChatRoomId chatRoomId) { .orElseThrow(ChatRoomNotFoundException::new); } - public Page getCompletedChatRoomsByMemberId(MemberId memberId, String keyword, Pageable pageable) { - return loadChatRoomPort.loadAliveChatRoomsByMemberId(memberId, keyword, pageable); + public Page getChatRoomsByMemberId(MemberId memberId, String keyword, Pageable pageable) { + return loadChatRoomPort.loadChatRoomsByMemberId(memberId, keyword, pageable); } public void validateChatRoomOwnership(MemberId memberId, ChatRoomId chatRoomId) { @@ -77,17 +73,14 @@ public void validateChatRoomsOwnership(MemberId memberId, List chatR } } - public void validateChatRoomAlive(MemberId memberId) { - loadChatRoomPort.loadCurrentChatRoomByMemberId(memberId) - .ifPresentOrElse(chatRoom -> { - if (!chatRoom.isChatRoomValid()) { - throw new NotValidChatRoomException(); - } - } - , () -> { - throw new ChatRoomNotFoundException(); - } - ); + // 채팅방 유효성 검증 (특정 채팅방 ID 기반) + public void validateChatRoomActive(ChatRoomId chatRoomId) { + ChatRoom chatRoom = loadChatRoomPort.loadChatRoomById(chatRoomId) + .orElseThrow(ChatRoomNotFoundException::new); + + if (!chatRoom.isChatRoomValid()) { + throw new NotValidChatRoomException(); + } } /* diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java index fd48e331..1cdc1edb 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java @@ -6,7 +6,6 @@ import makeus.cmc.malmo.domain.model.chat.DetailedPrompt; import org.springframework.stereotype.Component; -import java.util.List; import java.util.Optional; @Component @@ -25,4 +24,22 @@ public Optional getGuidelinePrompt(int level, int detailedLevel) return detailedPromptRepository.findByLevelAndDetailedLevelAndIsForGuidelineTrue(level, detailedLevel) .map(detailedPromptMapper::toDomain); } + + /** + * DetailedPrompt 조회 (fallback 지원) + * 요청한 레벨의 프롬프트가 없으면 3단계 1번 프롬프트 반환 + */ + public DetailedPrompt getGuidelinePromptWithFallback(int level, int detailedLevel) { + Optional prompt = detailedPromptRepository.findByLevelAndDetailedLevelAndIsForGuidelineTrue(level, detailedLevel) + .map(detailedPromptMapper::toDomain); + + if (prompt.isEmpty()) { + // 4단계 이상: 3단계 1번 프롬프트 재사용 + return detailedPromptRepository.findByLevelAndDetailedLevelAndIsForGuidelineTrue(3, 1) + .map(detailedPromptMapper::toDomain) + .orElseThrow(() -> new RuntimeException("Fallback prompt not found")); + } + + return prompt.get(); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java index d7fc28da..be857d92 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java @@ -17,29 +17,44 @@ public Prompt getSystemPrompt() { .orElseThrow(PromptNotFoundException::new); } - @Deprecated - public Prompt getSummaryPrompt() { - return loadPromptPort.loadSummaryPrompt() - .orElseThrow(PromptNotFoundException::new); - } - - public Prompt getSummaryPrompt(int level) { - return loadPromptPort.loadSummaryPromptByLevel(level) - .orElseThrow(PromptNotFoundException::new); - } - - public Prompt getTotalSummaryPrompt() { - return loadPromptPort.loadTotalSummaryPrompt() - .orElseThrow(PromptNotFoundException::new); - } public Prompt getGuidelinePrompt(int level) { return loadPromptPort.loadGuidelinePrompt(level) .orElseThrow(PromptNotFoundException::new); } + /** + * 프롬프트 조회 (fallback 지원) + * 요청한 레벨의 프롬프트가 없으면 3단계 프롬프트 반환 + */ + public Prompt getGuidelinePromptWithFallback(int level) { + Prompt prompt = loadPromptPort.loadGuidelinePrompt(level).orElse(null); + + if (prompt == null) { + // 4단계 이상: 3단계 프롬프트 재사용 + return loadPromptPort.loadGuidelinePrompt(3) + .orElseThrow(PromptNotFoundException::new); + } + + // isForCompletedResponse가 true인 경우도 무시하고 3단계 반환 + if (prompt.isForCompletedResponse()) { + return loadPromptPort.loadGuidelinePrompt(3) + .orElseThrow(PromptNotFoundException::new); + } + + return prompt; + } + public Prompt getAnswerMetadataPrompt() { return loadPromptPort.loadAnswerMetadataPrompt() .orElseThrow(PromptNotFoundException::new); } + + /** + * 제목 생성 프롬프트 조회 + */ + public Prompt getTitleGenerationPrompt() { + return loadPromptPort.loadTitleGenerationPrompt() + .orElseThrow(() -> new RuntimeException("Title generation prompt not found")); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/CompleteChatRoomUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CompleteChatRoomUseCase.java deleted file mode 100644 index a5832a3e..00000000 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/CompleteChatRoomUseCase.java +++ /dev/null @@ -1,20 +0,0 @@ -package makeus.cmc.malmo.application.port.in.chat; - -import lombok.Builder; -import lombok.Data; - -public interface CompleteChatRoomUseCase { - CompleteChatRoomResponse completeChatRoom(CompleteChatRoomCommand command); - - @Data - @Builder - class CompleteChatRoomCommand { - private Long userId; - } - - @Data - @Builder - class CompleteChatRoomResponse { - private Long chatRoomId; - } -} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateChatRoomUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateChatRoomUseCase.java new file mode 100644 index 00000000..cf29cf47 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateChatRoomUseCase.java @@ -0,0 +1,26 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Getter; +import makeus.cmc.malmo.domain.value.state.ChatRoomState; + +import java.time.LocalDateTime; + +public interface CreateChatRoomUseCase { + + CreateChatRoomResponse createChatRoom(CreateChatRoomCommand command); + + @Builder + @Getter + class CreateChatRoomCommand { + private final Long userId; + } + + @Builder + @Getter + class CreateChatRoomResponse { + private final Long chatRoomId; + private final ChatRoomState chatRoomState; + private final LocalDateTime createdAt; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomListUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomListUseCase.java index 0016e3cf..690d7977 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomListUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetChatRoomListUseCase.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.Data; +import makeus.cmc.malmo.domain.value.state.ChatRoomState; import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; @@ -30,9 +31,10 @@ class GetChatRoomListResponse { @Builder class GetChatRoomResponse { private Long chatRoomId; - private String totalSummary; - private String situationKeyword; - private String solutionKeyword; + private String title; // 제목 (nullable) + private ChatRoomState chatRoomState; // 상태 + private int level; // 현재 단계 + private LocalDateTime lastMessageSentTime; private LocalDateTime createdAt; } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java deleted file mode 100644 index 6b064bfc..00000000 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetCurrentChatRoomUseCase.java +++ /dev/null @@ -1,23 +0,0 @@ -package makeus.cmc.malmo.application.port.in.chat; - -import lombok.Builder; -import lombok.Data; -import makeus.cmc.malmo.domain.value.state.ChatRoomState; - -public interface GetCurrentChatRoomUseCase { - - GetCurrentChatRoomResponse getCurrentChatRoom(GetCurrentChatRoomCommand command); - - @Data - @Builder - class GetCurrentChatRoomCommand { - private Long userId; - } - - @Data - @Builder - class GetCurrentChatRoomResponse { - private Long chatRoomId; - private ChatRoomState chatRoomState; - } -} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java index 90a72df9..7bd155a8 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java @@ -8,8 +8,10 @@ public interface ProcessMessageUseCase { CompletableFuture processStreamChatMessage(ProcessMessageCommand command); - CompletableFuture processTotalSummary(ProcessTotalSummaryCommand command); CompletableFuture processAnswerMetadata(ProcessAnswerCommand command); + + // 제목 생성 처리 + CompletableFuture processTitleGeneration(ProcessTitleGenerationCommand command); @Data @Builder @@ -21,12 +23,6 @@ class ProcessMessageCommand { private int detailedLevel; } - @Data - @Builder - class ProcessTotalSummaryCommand { - private Long chatRoomId; - } - @Data @Builder class ProcessAnswerCommand { @@ -34,4 +30,10 @@ class ProcessAnswerCommand { private Long memberId; private Long coupleQuestionId; } + + @Data + @Builder + class ProcessTitleGenerationCommand { + private Long chatRoomId; + } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/SendChatMessageUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/SendChatMessageUseCase.java index 7f3d1fc6..61ae3f37 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/SendChatMessageUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/SendChatMessageUseCase.java @@ -12,6 +12,7 @@ public interface SendChatMessageUseCase { @Builder class SendChatMessageCommand { private Long userId; + private Long chatRoomId; // 명시적으로 채팅방 ID 지정 private String message; } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java index 6afad951..31350f6a 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java @@ -10,11 +10,15 @@ import java.util.Optional; public interface LoadChatRoomPort { - Optional loadCurrentChatRoomByMemberId(MemberId memberId); + // 진행 중인 채팅방 목록 조회 (복수) + List loadActiveChatRoomsByMemberId(MemberId memberId); + + // ID로 채팅방 조회 (유지) Optional loadChatRoomById(ChatRoomId chatRoomId); - Optional loadPausedChatRoomByMemberId(MemberId memberId); - - Page loadAliveChatRoomsByMemberId(MemberId memberId, String keyword, Pageable pageable); - + + // 삭제되지 않은 모든 채팅방 조회 (페이지네이션) + Page loadChatRoomsByMemberId(MemberId memberId, String keyword, Pageable pageable); + + // 소유권 검증 (유지) boolean isMemberOwnerOfChatRooms(MemberId memberId, List chatRoomIds); } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadPromptPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadPromptPort.java index d392c885..293975b4 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadPromptPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadPromptPort.java @@ -21,4 +21,6 @@ public interface LoadPromptPort { Optional loadAnswerMetadataPrompt(); Optional loadSummaryPromptByLevel(int level); + + Optional loadTitleGenerationPrompt(); } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java deleted file mode 100644 index 8a8c7094..00000000 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package makeus.cmc.malmo.application.port.out.chat; - -import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; - -public interface SaveChatMessageSummaryPort { - - void saveChatMessageSummary(ChatMessageSummary chatMessageSummary); - -} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index 03463fc6..1c0d2a05 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -6,17 +6,13 @@ import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.PromptQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.DetailedPromptQueryHelper; -import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataCommandHelper; import makeus.cmc.malmo.application.helper.member.MemberMemoryCommandHelper; import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; import makeus.cmc.malmo.application.helper.question.CoupleQuestionQueryHelper; import makeus.cmc.malmo.application.port.in.chat.ProcessMessageUseCase; import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult; -import makeus.cmc.malmo.application.port.out.sse.SendSseEventPort; -import makeus.cmc.malmo.application.port.out.chat.SaveChatMessageSummaryPort; import makeus.cmc.malmo.domain.model.chat.ChatMessage; -import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.model.chat.ChatRoom; import makeus.cmc.malmo.domain.model.chat.Prompt; import makeus.cmc.malmo.domain.model.chat.DetailedPrompt; @@ -54,12 +50,13 @@ public class ChatMessageService implements ProcessMessageUseCase { private final ChatRoomCommandHelper chatRoomCommandHelper; private final ChatRoomDomainService chatRoomDomainService; - private final SaveChatMessageSummaryPort saveChatMessageSummaryPort; private final CoupleQuestionQueryHelper coupleQuestionQueryHelper; private final MemberMemoryCommandHelper memberMemoryCommandHelper; + private final makeus.cmc.malmo.application.helper.outbox.OutboxHelper outboxHelper; + @Override public CompletableFuture processStreamChatMessage(ProcessMessageCommand command) { MemberId memberId = MemberId.of(command.getMemberId()); @@ -95,8 +92,10 @@ public CompletableFuture processStreamChatMessage(ProcessMessageCommand co } // 마지막 충분성 조건인 경우 - // 단계 요약 요청 (비동기) - requestStageSummaryAsync(chatRoom, command); + // 1단계 종료 시 제목 생성 요청 + if (command.getPromptLevel() == 1) { + requestTitleGenerationAsync(chatRoom); + } // 다음 단계 오프닝 생성 요청 chatRoom.upgradeToNextStage(); @@ -105,27 +104,6 @@ public CompletableFuture processStreamChatMessage(ProcessMessageCommand co }); } - @Override - public CompletableFuture processTotalSummary(ProcessTotalSummaryCommand command) { - ChatRoom chatRoom = chatRoomQueryHelper.getChatRoomByIdOrThrow(ChatRoomId.of(command.getChatRoomId())); - - Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); - Prompt totalSummaryPrompt = promptQueryHelper.getTotalSummaryPrompt(); - - List> messages = chatPromptBuilder.createForTotalSummary(chatRoom); - - return chatProcessor.requestTotalSummary(messages, systemPrompt, totalSummaryPrompt) - .thenAcceptAsync(summary -> { // CounselingSummary 객체를 받음 - chatRoom.updateChatRoomSummary( - summary.getTotalSummary(), - summary.getSituationKeyword(), - summary.getSolutionKeyword(), - summary.getCounselingType() - ); - chatRoomCommandHelper.saveChatRoom(chatRoom); - log.info("Successfully processed and saved total summary for chatRoomId: {}", command.getChatRoomId()); - }); - } @Override public CompletableFuture processAnswerMetadata(ProcessAnswerCommand command) { @@ -157,15 +135,6 @@ public CompletableFuture processAnswerMetadata(ProcessAnswerCommand comman }); } - private void saveUserMessage(ChatRoom chatRoom, ProcessMessageCommand command) { - ChatMessage userMessage = chatRoomDomainService.createUserMessage( - ChatRoomId.of(chatRoom.getId()), - command.getPromptLevel(), - command.getDetailedLevel(), - command.getNowMessage() - ); - chatRoomCommandHelper.saveChatMessage(userMessage); - } private CompletableFuture requestSufficiencyCheck(Member member, ChatRoom chatRoom, ProcessMessageCommand command) { List> messages = chatPromptBuilder.createForSufficiencyCheck( @@ -190,10 +159,9 @@ private CompletableFuture requestResponseToMeetCondition(Member member, Ch } Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); - Prompt prompt = promptQueryHelper.getGuidelinePrompt(command.getPromptLevel()); - DetailedPrompt detailedPrompt = detailedPromptQueryHelper.getGuidelinePrompt( - command.getPromptLevel(), command.getDetailedLevel()) - .orElseThrow(() -> new RuntimeException("Guideline prompt not found")); + Prompt prompt = promptQueryHelper.getGuidelinePromptWithFallback(command.getPromptLevel()); + DetailedPrompt detailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback( + command.getPromptLevel(), command.getDetailedLevel()); return chatProcessor.streamChat(messages, systemPrompt, prompt, detailedPrompt, chunk -> chatSseSender.sendResponseChunk(MemberId.of(member.getId()), chunk), @@ -229,10 +197,9 @@ private CompletableFuture requestNextDetailedPromptOpening(ChatRoom chatRo // 시스템 프롬프트 + 현재 단계 프롬프트 + 다음 충분성 조건 프롬프트 Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); - Prompt prompt = promptQueryHelper.getGuidelinePrompt(chatRoom.getLevel()); - DetailedPrompt nextDetailedPrompt = detailedPromptQueryHelper.getGuidelinePrompt( - command.getPromptLevel(), command.getDetailedLevel() + 1) - .orElseThrow(() -> new RuntimeException("Next guideline prompt not found")); + Prompt prompt = promptQueryHelper.getGuidelinePromptWithFallback(chatRoom.getLevel()); + DetailedPrompt nextDetailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback( + command.getPromptLevel(), command.getDetailedLevel() + 1); return chatProcessor.streamChat(messages, systemPrompt, prompt, nextDetailedPrompt, chunk -> chatSseSender.sendResponseChunk(memberId, chunk), @@ -242,52 +209,58 @@ private CompletableFuture requestNextDetailedPromptOpening(ChatRoom chatRo ).toFuture(); } - private void requestStageSummaryAsync(ChatRoom chatRoom, ProcessMessageCommand command) { - List> messages = chatPromptBuilder.createForStageSummary( - chatRoom, command.getPromptLevel()); + private CompletableFuture requestNextStageOpening(Member member, ChatRoom chatRoom, ProcessMessageCommand command) { + int nextLevel = command.getPromptLevel() + 1; + + // 단계별 요약 없이 컨텍스트 구성 + List> messages = chatPromptBuilder.createForNextStage(member, chatRoom, nextLevel); Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); - Prompt prompt = promptQueryHelper.getGuidelinePrompt(command.getPromptLevel()); - Prompt summaryPrompt = promptQueryHelper.getSummaryPrompt(command.getPromptLevel()); - chatProcessor.requestStageSummary(messages, systemPrompt, prompt, summaryPrompt) - .thenAcceptAsync(summary -> { - ChatMessageSummary chatMessageSummary = ChatMessageSummary.createChatMessageSummary( - ChatRoomId.of(chatRoom.getId()), summary, command.getPromptLevel()); - saveChatMessageSummaryPort.saveChatMessageSummary(chatMessageSummary); - log.info("Stage summary completed for chatRoomId: {}, level: {}", - chatRoom.getId(), command.getPromptLevel()); - }); - } - - private CompletableFuture requestNextStageOpening(Member member, ChatRoom chatRoom, ProcessMessageCommand command) { - List> messages = chatPromptBuilder.createForNextStage( - member, chatRoom, command.getPromptLevel() + 1); + // 4단계 이상에서는 3단계 프롬프트 재사용 + Prompt nextPrompt = promptQueryHelper.getGuidelinePromptWithFallback(nextLevel); - Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); - Prompt nextPrompt = promptQueryHelper.getGuidelinePrompt(command.getPromptLevel() + 1); - - if (nextPrompt.isForCompletedResponse()) { - String finalMessage = nextPrompt.getContent(); - saveAiMessage(MemberId.of(member.getId()), ChatRoomId.of(chatRoom.getId()), - command.getPromptLevel(), command.getDetailedLevel(), finalMessage); - chatSseSender.sendLastResponse(chatRoom.getMemberId(), finalMessage); - - return CompletableFuture.completedFuture(null); - } - - DetailedPrompt nextDetailedPrompt = detailedPromptQueryHelper.getGuidelinePrompt( - command.getPromptLevel() + 1, 1) - .orElseThrow(() -> new RuntimeException("Next stage guideline prompt not found")); + // DetailedPrompt도 fallback 로직 적용 + DetailedPrompt nextDetailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback(nextLevel, 1); return chatProcessor.streamChat(messages, systemPrompt, nextPrompt, nextDetailedPrompt, chunk -> chatSseSender.sendResponseChunk(MemberId.of(member.getId()), chunk), fullAnswer -> saveAiMessage(MemberId.of(member.getId()), ChatRoomId.of(chatRoom.getId()), - command.getPromptLevel() + 1, 1, fullAnswer), + nextLevel, 1, fullAnswer), errorMessage -> chatSseSender.sendError(MemberId.of(member.getId()), errorMessage) ).toFuture(); } + /** + * 비동기 제목 생성 요청 + * Redis Stream을 통해 제목 생성 워커에 전달 + */ + private void requestTitleGenerationAsync(ChatRoom chatRoom) { + outboxHelper.publish( + makeus.cmc.malmo.adaptor.message.StreamMessageType.REQUEST_TITLE_GENERATION, + new makeus.cmc.malmo.adaptor.message.RequestTitleGenerationMessage(chatRoom.getId()) + ); + log.info("Title generation requested for chatRoomId: {}", chatRoom.getId()); + } + + @Override + public CompletableFuture processTitleGeneration(ProcessTitleGenerationCommand command) { + ChatRoom chatRoom = chatRoomQueryHelper.getChatRoomByIdOrThrow(ChatRoomId.of(command.getChatRoomId())); + + // 1단계 대화 내용 조회 + List> messages = chatPromptBuilder.createForTitleGeneration(chatRoom); + + // 제목 생성 프롬프트 조회 + Prompt titlePrompt = promptQueryHelper.getTitleGenerationPrompt(); + + return chatProcessor.requestTitleGeneration(messages, titlePrompt) + .thenAcceptAsync(title -> { + chatRoom.updateTitle(title); + chatRoomCommandHelper.saveChatRoom(chatRoom); + log.info("Title generated for chatRoomId: {}, title: {}", command.getChatRoomId(), title); + }); + } + private void saveAiMessage(MemberId memberId, ChatRoomId chatRoomId, int level, int detailedLevel, String fullAnswer) { // fullAnswer를 문장 단위로 분할하고 세 문장씩 그룹화 List groupedTexts = ChatMessageSplitter.splitIntoGroups(fullAnswer); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java index 735e3700..3339c37f 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java @@ -2,9 +2,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import makeus.cmc.malmo.application.port.out.chat.RequestChatApiPort; @@ -15,6 +12,7 @@ import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -65,26 +63,6 @@ public CompletableFuture requestSummaryAsync(List> m return requestChatApiPort.requestResponse(messages); } - public CompletableFuture requestTotalSummary(List> messages, - Prompt systemPrompt, - Prompt totalSummaryPrompt) { - messages.add(createMessageMap(SenderType.SYSTEM, systemPrompt.getContent())); - messages.add(createMessageMap(SenderType.SYSTEM, "[현재 단계 지시] " + totalSummaryPrompt.getContent())); - - // 비동기 API 호출 후 CompletableFuture 형태로 응답 - return requestChatApiPort.requestJsonResponse(messages) - // 응답(JSON 문자열)이 오면, thenApply를 통해 다음 작업을 연결 - .thenApply(summaryJson -> { - try { - // JSON 문자열을 CounselingSummary 객체로 파싱 - return objectMapper.readValue(summaryJson, CounselingSummary.class); - } catch (JsonProcessingException e) { - log.error("Failed to parse summary JSON: {}", summaryJson, e); - // 예외 발생 시, 런타임 예외로 감싸서 CompletableFuture가 예외를 인지 - throw new RuntimeException("Failed to parse summary JSON", e); - } - }); - } public CompletableFuture requestMetaData(String question, String memberAnswer, @@ -122,14 +100,24 @@ public CompletableFuture requestDetailedSummary(List return requestChatApiPort.requestResponse(messages); } - public CompletableFuture requestStageSummary(List> messages, - Prompt systemPrompt, - Prompt prompt, - Prompt summaryPrompt) { - messages.add(createMessageMap(SenderType.SYSTEM, systemPrompt.getContent())); - messages.add(createMessageMap(SenderType.SYSTEM, prompt.getContent())); - messages.add(createMessageMap(SenderType.SYSTEM, summaryPrompt.getContent())); - return requestChatApiPort.requestResponse(messages); + + /** + * 제목 생성 요청 + * @return 생성된 제목 문자열 + */ + public CompletableFuture requestTitleGeneration(List> messages, Prompt titlePrompt) { + // OpenAI API 호출하여 제목 생성 + // 짧은 제목 (20자 이내) 생성하도록 프롬프트 구성 + + List> promptMessages = new ArrayList<>(messages); + promptMessages.add(createMessageMap(SenderType.SYSTEM, titlePrompt.getContent())); + + return requestChatApiPort.requestResponse(promptMessages) + .thenApply(title -> { + // 제목 길이 제한 (최대 50자) + String trimmedTitle = title.trim(); + return trimmedTitle.length() > 50 ? trimmedTitle.substring(0, 50) : trimmedTitle; + }); } private Map createMessageMap(SenderType senderType, String content) { @@ -139,13 +127,4 @@ private Map createMessageMap(SenderType senderType, String conte ); } - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class CounselingSummary { - private String totalSummary; - private String situationKeyword; - private String solutionKeyword; - private String counselingType; - } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java index f1471cdb..91a08cd2 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java @@ -5,12 +5,10 @@ import makeus.cmc.malmo.application.helper.chat_room.MemberChatRoomMetadataQueryHelper; import makeus.cmc.malmo.application.port.out.chat.LoadChatRoomMetadataPort; import makeus.cmc.malmo.domain.model.chat.ChatMessage; -import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.model.chat.ChatRoom; import makeus.cmc.malmo.domain.model.chat.MemberChatRoomMetadata; import makeus.cmc.malmo.domain.model.member.Member; import makeus.cmc.malmo.domain.model.member.MemberMemory; -import makeus.cmc.malmo.domain.service.MemberDomainService; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.type.SenderType; @@ -24,7 +22,6 @@ @RequiredArgsConstructor public class ChatPromptBuilder { - private final MemberDomainService memberDomainService; private final ChatRoomQueryHelper chatRoomQueryHelper; private final MemberChatRoomMetadataQueryHelper memberChatRoomMetadataQueryHelper; @@ -36,11 +33,11 @@ public List> createForProcessUserMessage(Member member, Chat String metaDataContent = getMetaDataContent(member); messages.add(createMessageMap(SenderType.USER, metaDataContent)); - // 2. 이전 단계 요약본 - List previousLevelsSummarizedMessages = chatRoomQueryHelper.getSummarizedMessages(ChatRoomId.of(chatRoom.getId())); - if (!previousLevelsSummarizedMessages.isEmpty()) { - String summarizedMessageContent = getSummarizedMessageContent(previousLevelsSummarizedMessages); - messages.add(createMessageMap(SenderType.SYSTEM, summarizedMessageContent)); + // 2. MemberChatRoomMetadata 정보 (단계별 요약 대신) + List metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(ChatRoomId.of(chatRoom.getId())); + if (!metadataList.isEmpty()) { + String metadataContent = getMemberChatRoomMetadataContent(metadataList); + messages.add(createMessageMap(SenderType.SYSTEM, metadataContent)); } // 3. 현재 단계 메시지들 @@ -63,53 +60,7 @@ public List> createForProcessUserMessage(Member member, Chat return messages; } - public List> createForSummaryAsync(ChatRoom chatRoom) { - List> messages = new ArrayList<>(); - int chatRoomLevel = chatRoom.getLevel(); - - // 현재 단계 메시지들 - List currentChatRoomMessages = chatRoomQueryHelper.getChatRoomLevelMessages(ChatRoomId.of(chatRoom.getId()), chatRoomLevel); - for (ChatMessage chatMessage : currentChatRoomMessages) { - messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent())); - } - return messages; - } - - public List> createForTotalSummary(ChatRoom chatRoom) { - List> messages = new ArrayList<>(); - List summarizedMessages = chatRoomQueryHelper.getSummarizedMessages(ChatRoomId.of(chatRoom.getId())); - - if (summarizedMessages.isEmpty()) { - List lastLevelMessages = chatRoomQueryHelper.getChatRoomLevelMessages(ChatRoomId.of(chatRoom.getId()), chatRoom.getLevel()); - for (ChatMessage lastLevelMessage : lastLevelMessages) { - messages.add( - createMessageMap(lastLevelMessage.getSenderType(), lastLevelMessage.getContent()) - ); - } - } else { - StringBuilder sb = new StringBuilder(); - for (ChatMessageSummary summary : summarizedMessages) { - sb.append("[").append(summary.getLevel()).append(" 단계 요약] \n"); - sb.append(summary.getContent()).append("\n"); - } - messages.add( - createMessageMap(SenderType.SYSTEM, sb.toString()) - ); - } - return messages; - } - private String getSummarizedMessageContent(List summarizedMessages) { - if (summarizedMessages == null || summarizedMessages.isEmpty()) { - return ""; - } - StringBuilder sb = new StringBuilder(); - sb.append("[이전 단계 요약] \n"); - for (ChatMessageSummary summary : summarizedMessages) { - sb.append("- ").append(summary.getContent()).append("\n"); - } - return sb.toString(); - } private Map createMessageMap(SenderType senderType, String content) { return Map.of( @@ -157,21 +108,14 @@ public List> createForSufficiencyCheck(Member member, ChatRo String metaDataContent = getMetaDataContent(member); messages.add(createMessageMap(SenderType.USER, metaDataContent)); - // 2. 이전 단계 요약본 - List previousLevelsSummarizedMessages = chatRoomQueryHelper.getSummarizedMessages(ChatRoomId.of(chatRoom.getId())); - if (!previousLevelsSummarizedMessages.isEmpty()) { - String summarizedMessageContent = getSummarizedMessageContent(previousLevelsSummarizedMessages); - messages.add(createMessageMap(SenderType.SYSTEM, summarizedMessageContent)); - } - - // 3. MemberChatRoomMetadata 정보 + // 2. MemberChatRoomMetadata 정보 (단계별 요약 대신) List metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(ChatRoomId.of(chatRoom.getId())); if (!metadataList.isEmpty()) { String metadataContent = getMemberChatRoomMetadataContent(metadataList); messages.add(createMessageMap(SenderType.SYSTEM, metadataContent)); } - // 4. 현재 단계 메시지들 + // 3. 현재 단계 메시지들 List currentChatRoomMessages = chatRoomQueryHelper.getChatRoomLevelAndDetailedLevelMessages(ChatRoomId.of(chatRoom.getId()), level, detailedLevel); for (ChatMessage chatMessage : currentChatRoomMessages) { messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent())); @@ -180,17 +124,6 @@ public List> createForSufficiencyCheck(Member member, ChatRo return messages; } - public List> createForStageSummary(ChatRoom chatRoom, int level) { - List> messages = new ArrayList<>(); - - // 현재 단계 메시지들 - List currentChatRoomMessages = chatRoomQueryHelper.getChatRoomLevelMessages(ChatRoomId.of(chatRoom.getId()), level); - for (ChatMessage chatMessage : currentChatRoomMessages) { - messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent())); - } - - return messages; - } public List> createForNextDetailedPrompt(Member member, ChatRoom chatRoom, int level, int nextDetailedLevel) { List> messages = new ArrayList<>(); @@ -199,21 +132,14 @@ public List> createForNextDetailedPrompt(Member member, Chat String metaDataContent = getMetaDataContent(member); messages.add(createMessageMap(SenderType.USER, metaDataContent)); - // 2. 이전 단계 요약본 - List previousLevelsSummarizedMessages = chatRoomQueryHelper.getSummarizedMessages(ChatRoomId.of(chatRoom.getId())); - if (!previousLevelsSummarizedMessages.isEmpty()) { - String summarizedMessageContent = getSummarizedMessageContent(previousLevelsSummarizedMessages); - messages.add(createMessageMap(SenderType.SYSTEM, summarizedMessageContent)); - } - - // 3. MemberChatRoomMetadata 정보 + // 2. MemberChatRoomMetadata 정보 List metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(ChatRoomId.of(chatRoom.getId())); if (!metadataList.isEmpty()) { String metadataContent = getMemberChatRoomMetadataContent(metadataList); messages.add(createMessageMap(SenderType.SYSTEM, metadataContent)); } - // 4. 현재 단계 메시지들 (이전 detailedLevel까지) + // 3. 현재 단계 메시지들 (이전 detailedLevel까지) // List currentChatRoomMessages = chatRoomQueryHelper.getChatRoomLevelAndDetailedLevelMessages(ChatRoomId.of(chatRoom.getId()), level, nextDetailedLevel - 1); // fixed: 현재 단계 메시지들 context 전체 전달(level 기준) List currentChatRoomMessages = chatRoomQueryHelper.getChatRoomLevelMessages(ChatRoomId.of(chatRoom.getId()), level); @@ -231,14 +157,7 @@ public List> createForNextStage(Member member, ChatRoom chat String metaDataContent = getMetaDataContent(member); messages.add(createMessageMap(SenderType.USER, metaDataContent)); - // 2. 이전 단계 요약본 - List previousLevelsSummarizedMessages = chatRoomQueryHelper.getSummarizedMessages(ChatRoomId.of(chatRoom.getId())); - if (!previousLevelsSummarizedMessages.isEmpty()) { - String summarizedMessageContent = getSummarizedMessageContent(previousLevelsSummarizedMessages); - messages.add(createMessageMap(SenderType.SYSTEM, summarizedMessageContent)); - } - - // 3. MemberChatRoomMetadata 정보 + // 2. MemberChatRoomMetadata 정보 List metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(ChatRoomId.of(chatRoom.getId())); if (!metadataList.isEmpty()) { String metadataContent = getMemberChatRoomMetadataContent(metadataList); @@ -259,4 +178,22 @@ private String getMemberChatRoomMetadataContent(List met } return sb.toString(); } + + /** + * 제목 생성을 위한 메시지 구성 + * 1단계 대화 내용만 포함 + */ + public List> createForTitleGeneration(ChatRoom chatRoom) { + List> messages = new ArrayList<>(); + + // 1단계 메시지들만 조회 + List stage1Messages = chatRoomQueryHelper.getChatRoomLevelMessages( + ChatRoomId.of(chatRoom.getId()), 1); + + for (ChatMessage chatMessage : stage1Messages) { + messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent())); + } + + return messages; + } } diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java new file mode 100644 index 00000000..c7015930 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java @@ -0,0 +1,58 @@ +package makeus.cmc.malmo.application.service.chat; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.adaptor.in.aop.CheckValidMember; +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomCommandHelper; +import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; +import makeus.cmc.malmo.application.port.in.chat.CreateChatRoomUseCase; +import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.model.chat.ChatRoom; +import makeus.cmc.malmo.domain.model.member.Member; +import makeus.cmc.malmo.domain.service.ChatRoomDomainService; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.util.JosaUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHATROOM_LEVEL; +import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHAT_MESSAGE; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatRoomManagementService implements CreateChatRoomUseCase { + + private final ChatRoomDomainService chatRoomDomainService; + private final MemberQueryHelper memberQueryHelper; + private final ChatRoomCommandHelper chatRoomCommandHelper; + + @Override + @Transactional + @CheckValidMember + public CreateChatRoomResponse createChatRoom(CreateChatRoomCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + Member member = memberQueryHelper.getMemberByIdOrThrow(memberId); + + // 채팅방 생성 (즉시 ALIVE 상태) + ChatRoom chatRoom = chatRoomDomainService.createChatRoom(memberId); + ChatRoom savedChatRoom = chatRoomCommandHelper.saveChatRoom(chatRoom); + + // 초기 AI 메시지 생성 및 저장 + ChatMessage initMessage = chatRoomDomainService.createAiMessage( + ChatRoomId.of(savedChatRoom.getId()), + INIT_CHATROOM_LEVEL, + 1, + JosaUtils.아야(member.getNickname()) + INIT_CHAT_MESSAGE); + chatRoomCommandHelper.saveChatMessage(initMessage); + + log.info("새 채팅방 생성: chatRoomId={}, memberId={}", savedChatRoom.getId(), memberId.getValue()); + + return CreateChatRoomResponse.builder() + .chatRoomId(savedChatRoom.getId()) + .chatRoomState(savedChatRoom.getChatRoomState()) + .createdAt(savedChatRoom.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java index cb2afddb..c62fdcf9 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java @@ -54,17 +54,18 @@ public GetChatRoomSummaryResponse getChatRoomSummary(GetChatRoomSummaryCommand c @Override @CheckValidMember public GetChatRoomListResponse getChatRoomList(GetChatRoomListCommand command) { - Page chatRoomList = chatRoomQueryHelper.getCompletedChatRoomsByMemberId( + Page chatRoomList = chatRoomQueryHelper.getChatRoomsByMemberId( MemberId.of(command.getUserId()), command.getKeyword(), command.getPageable() ); List response = chatRoomList.getContent().stream() .map(chatRoom -> GetChatRoomResponse.builder() .chatRoomId(chatRoom.getId()) - .totalSummary(chatRoom.getTotalSummary()) - .situationKeyword(chatRoom.getSituationKeyword()) - .solutionKeyword(chatRoom.getSolutionKeyword()) - .createdAt(chatRoom.getLastMessageSentTime()) + .title(chatRoom.getTitle()) // 제목 (null일 수 있음) + .chatRoomState(chatRoom.getChatRoomState()) // 상태 포함 + .level(chatRoom.getLevel()) // 현재 단계 + .lastMessageSentTime(chatRoom.getLastMessageSentTime()) + .createdAt(chatRoom.getCreatedAt()) .build()) .toList(); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java index b7d73040..02bb8b7a 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java @@ -7,24 +7,20 @@ import makeus.cmc.malmo.adaptor.message.StreamMessageType; import makeus.cmc.malmo.application.helper.chat_room.ChatRoomCommandHelper; import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; -import makeus.cmc.malmo.application.helper.chat_room.PromptQueryHelper; import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; import makeus.cmc.malmo.application.helper.outbox.OutboxHelper; import makeus.cmc.malmo.application.port.in.chat.SendChatMessageUseCase; import makeus.cmc.malmo.domain.model.chat.ChatMessage; import makeus.cmc.malmo.domain.model.chat.ChatRoom; -import makeus.cmc.malmo.domain.model.chat.Prompt; import makeus.cmc.malmo.domain.model.member.Member; import makeus.cmc.malmo.domain.service.ChatRoomDomainService; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; -import makeus.cmc.malmo.domain.value.state.ChatRoomState; import makeus.cmc.malmo.util.ChatMessageSplitter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; //import static makeus.cmc.malmo.util.GlobalConstants.FINAL_MESSAGE; @@ -40,7 +36,6 @@ public class ChatService implements SendChatMessageUseCase { private final MemberQueryHelper memberQueryHelper; private final ChatRoomQueryHelper chatRoomQueryHelper; private final ChatRoomCommandHelper chatRoomCommandHelper; - private final PromptQueryHelper promptQueryHelper; private final OutboxHelper outboxHelper; @@ -48,24 +43,15 @@ public class ChatService implements SendChatMessageUseCase { @Transactional @CheckValidMember public SendChatMessageResponse processUserMessage(SendChatMessageCommand command) { - // 활성화된 채팅방이 있는지 확인 MemberId memberId = MemberId.of(command.getUserId()); - chatRoomQueryHelper.validateChatRoomAlive(memberId); - + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 명시적 채팅방 ID로 조회 및 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + chatRoomQueryHelper.validateChatRoomActive(chatRoomId); + Member member = memberQueryHelper.getMemberByIdOrThrow(memberId); - ChatRoom chatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(memberId); - - // 채팅방의 상담 단계가 마지막인 경우 동일한 메시지를 반복하여 전송 - Prompt prompt = promptQueryHelper.getGuidelinePrompt(chatRoom.getLevel()); - if (prompt.isForCompletedResponse()) { - String finalMessage = prompt.getContent(); - return handleLastPrompt(chatRoom, command.getMessage(), finalMessage); - } - - // 채팅방이 초기화되지 않은 상태인 경우 초기화 - if (chatRoom.getChatRoomState() == ChatRoomState.BEFORE_INIT) { - chatRoom.updateChatRoomStateAlive(); - } + ChatRoom chatRoom = chatRoomQueryHelper.getChatRoomByIdOrThrow(chatRoomId); // 현재 유저 메시지를 저장 ChatMessage savedUserMessage = saveUserMessage(chatRoom, command.getMessage()); @@ -91,20 +77,6 @@ public SendChatMessageResponse processUserMessage(SendChatMessageCommand command .build(); } - // upgradeChatRoom 메서드 제거 - 내부 로직으로 통합됨 - - private SendChatMessageResponse handleLastPrompt(ChatRoom chatRoom, String userMessage, String finalMessage) { - // 마지막 단계에서 고정된 메시지를 반복하여 전송 - ChatMessage savedUserMessage = saveUserMessage(chatRoom, userMessage); - - chatSseSender.sendLastResponse(chatRoom.getMemberId(), finalMessage); - saveAiMessage(chatRoom.getMemberId(), ChatRoomId.of(chatRoom.getId()), - chatRoom.getLevel(), chatRoom.getDetailedLevel(), finalMessage); - return SendChatMessageResponse.builder() - .messageId(savedUserMessage.getId()) - .build(); - } - private ChatMessage saveUserMessage(ChatRoom chatRoom, String message) { ChatMessage userMessage = chatRoomDomainService.createUserMessage( ChatRoomId.of(chatRoom.getId()), diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java deleted file mode 100644 index d4303c2e..00000000 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java +++ /dev/null @@ -1,149 +0,0 @@ -package makeus.cmc.malmo.application.service.chat; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import makeus.cmc.malmo.adaptor.in.aop.CheckValidMember; -import makeus.cmc.malmo.adaptor.message.RequestTotalSummaryMessage; -import makeus.cmc.malmo.adaptor.message.StreamMessageType; -import makeus.cmc.malmo.application.helper.chat_room.ChatRoomCommandHelper; -import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; -import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; -import makeus.cmc.malmo.application.helper.outbox.OutboxHelper; -import makeus.cmc.malmo.application.port.in.chat.CompleteChatRoomUseCase; -import makeus.cmc.malmo.application.port.in.chat.GetCurrentChatRoomMessagesUseCase; -import makeus.cmc.malmo.application.port.in.chat.GetCurrentChatRoomUseCase; -import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; -import makeus.cmc.malmo.domain.model.chat.ChatMessage; -import makeus.cmc.malmo.domain.model.chat.ChatRoom; -import makeus.cmc.malmo.domain.model.member.Member; -import makeus.cmc.malmo.domain.service.ChatRoomDomainService; -import makeus.cmc.malmo.domain.value.id.ChatRoomId; -import makeus.cmc.malmo.domain.value.id.MemberId; -import makeus.cmc.malmo.util.ChatMessageSplitter; -import makeus.cmc.malmo.util.JosaUtils; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Collectors; - -import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHATROOM_LEVEL; -import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHAT_MESSAGE; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CurrentChatRoomService - implements GetCurrentChatRoomUseCase, GetCurrentChatRoomMessagesUseCase, CompleteChatRoomUseCase { - - private final ChatRoomDomainService chatRoomDomainService; - private final ChatRoomQueryHelper chatRoomQueryHelper; - private final MemberQueryHelper memberQueryHelper; - private final ChatRoomCommandHelper chatRoomCommandHelper; - - private final OutboxHelper outboxHelper; - - @Override - @Transactional - @CheckValidMember - public GetCurrentChatRoomResponse getCurrentChatRoom(GetCurrentChatRoomCommand command) { - // 현재 채팅방 가져오기 - ChatRoom currentChatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberId(MemberId.of(command.getUserId())) - .map(chatRoom -> { - if (chatRoom.isStarted() && chatRoomDomainService.isChatRoomExpired(chatRoom.getLastMessageSentTime())) { - // 마지막 채팅 이후 하루가 지난 경우 채팅방 종료 처리 - chatRoom.expire(); - ChatRoom savedChatRoom = chatRoomCommandHelper.saveChatRoom(chatRoom); - outboxHelper.publish( - StreamMessageType.REQUEST_TOTAL_SUMMARY, - new RequestTotalSummaryMessage(savedChatRoom.getId()) - ); - - return createAndSaveNewChatRoom(MemberId.of(command.getUserId())); - } - - return chatRoom; - }) - .orElseGet(() -> { - // 현재 채팅방이 없으면 새로 생성 - return createAndSaveNewChatRoom(MemberId.of(command.getUserId())); - }); - - return GetCurrentChatRoomResponse.builder() - .chatRoomId(currentChatRoom.getId()) - .chatRoomState(currentChatRoom.getChatRoomState()) - .build(); - } - - private ChatRoom createAndSaveNewChatRoom(MemberId memberId) { - // 새로운 채팅방 생성 - Member member = memberQueryHelper.getMemberByIdOrThrow(memberId); - ChatRoom chatRoom = chatRoomDomainService.createChatRoom(memberId); - ChatRoom savedChatRoom = chatRoomCommandHelper.saveChatRoom(chatRoom); - - // 초기 메시지 생성 및 저장 - String initMessageContent = JosaUtils.아야(member.getNickname()) + INIT_CHAT_MESSAGE; - List groupedTexts = ChatMessageSplitter.splitIntoGroups(initMessageContent); - List chatMessages = groupedTexts.stream() - .map(groupText -> chatRoomDomainService.createAiMessage( - ChatRoomId.of(savedChatRoom.getId()), - INIT_CHATROOM_LEVEL, - 1, - groupText)) - .collect(Collectors.toList()); - - chatRoomCommandHelper.saveChatMessages(chatMessages); - - return savedChatRoom; - } - - @Override - @CheckValidMember - public GetCurrentChatRoomMessagesResponse getCurrentChatRoomMessages(GetCurrentChatRoomMessagesCommand command) { - // 현재 채팅방 가져오기 - MemberId memberId = MemberId.of(command.getUserId()); - ChatRoom currentChatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(memberId); - - Page result = - chatRoomQueryHelper.getChatMessagesDtoDesc(ChatRoomId.of(currentChatRoom.getId()), memberId, command.getPageable()); - - List list = result.stream().map(cm -> - ChatRoomMessageDto.builder() - .messageId(cm.getMessageId()) - .senderType(cm.getSenderType()) - .content(cm.getContent()) - .createdAt(cm.getCreatedAt()) - .bookmarkId(cm.getBookmarkId()) - .build()) - .toList(); - - return GetCurrentChatRoomMessagesResponse.builder() - .messages(list) - .totalCount(result.getTotalElements()) - .build(); - } - - @Override - @Transactional - @CheckValidMember - public CompleteChatRoomResponse completeChatRoom(CompleteChatRoomCommand command) { - ChatRoom chatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(MemberId.of(command.getUserId())); - chatRoom.completeByUser(); - chatRoomCommandHelper.saveChatRoom(chatRoom); - - // 완료된 채팅방의 요약을 요청 - log.info("채팅방 요약 요청 스트림 추가: chatRoomId={}", chatRoom.getId()); - outboxHelper.publish( - StreamMessageType.REQUEST_TOTAL_SUMMARY, - new RequestTotalSummaryMessage(chatRoom.getId()) - ); - - // 사용자에게는 즉시 성공 응답 반환 - return CompleteChatRoomResponse.builder() - .chatRoomId(chatRoom.getId()) - .build(); - } - -} - diff --git a/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java b/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java index e5c29bdf..3873e97b 100644 --- a/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java +++ b/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java @@ -11,8 +11,6 @@ import java.util.Objects; import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHATROOM_LEVEL; -import static makeus.cmc.malmo.util.GlobalConstants.COMPLETED_ROOM_CREATING_SUMMARY_LINE; -import static makeus.cmc.malmo.util.GlobalConstants.EXPIRED_ROOM_CREATING_SUMMARY_LINE; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -23,6 +21,9 @@ public class ChatRoom { private int level; private int detailedLevel; private LocalDateTime lastMessageSentTime; + private String title; // 채팅방 제목 (1단계 종료 후 생성) + + // 유지: 기존 COMPLETED 채팅방 보고서 조회용 필드들 private String totalSummary; private String situationKeyword; private String solutionKeyword; @@ -39,13 +40,21 @@ public static ChatRoom createChatRoom(MemberId memberId) { .memberId(memberId) .level(INIT_CHATROOM_LEVEL) .detailedLevel(1) - .chatRoomState(ChatRoomState.BEFORE_INIT) + .chatRoomState(ChatRoomState.ALIVE) .lastMessageSentTime(LocalDateTime.now()) + .title(null) // 제목은 1단계 종료 후 생성 + // 새 채팅방은 보고서 관련 필드 null + .totalSummary(null) + .situationKeyword(null) + .solutionKeyword(null) + .chatRoomCompletedReason(null) + .counselingType(null) .build(); } public static ChatRoom from(Long id, MemberId memberId, ChatRoomState chatRoomState, int level, int detailedLevel, LocalDateTime lastMessageSentTime, + String title, String totalSummary, String situationKeyword, String solutionKeyword, ChatRoomCompletedReason chatRoomCompletedReason, String counselingType, LocalDateTime createdAt, LocalDateTime modifiedAt, LocalDateTime deletedAt) { @@ -56,6 +65,8 @@ public static ChatRoom from(Long id, MemberId memberId, ChatRoomState chatRoomSt .level(level) .detailedLevel(detailedLevel) .lastMessageSentTime(lastMessageSentTime) + .title(title) + // 기존 데이터 매핑용 .totalSummary(totalSummary) .situationKeyword(situationKeyword) .solutionKeyword(solutionKeyword) @@ -76,42 +87,29 @@ public void upgradeToNextStage() { this.detailedLevel = 1; } - public void updateChatRoomStateAlive() { - this.chatRoomState = ChatRoomState.ALIVE; - } - - public void updateChatRoomSummary(String totalSummary, String situationKeyword, String solutionKeyword, String counselingType) { - this.totalSummary = totalSummary; - this.situationKeyword = situationKeyword; - this.solutionKeyword = solutionKeyword; - this.counselingType = counselingType; - } - public void updateLastMessageSentTime() { this.lastMessageSentTime = LocalDateTime.now(); } - public void completeByUser() { - this.chatRoomState = ChatRoomState.COMPLETED; - this.totalSummary = COMPLETED_ROOM_CREATING_SUMMARY_LINE; - this.chatRoomCompletedReason = ChatRoomCompletedReason.COMPLETED_BY_USER; + public void updateTitle(String title) { + this.title = title; } public boolean isChatRoomValid() { - return this.chatRoomState == ChatRoomState.ALIVE || this.chatRoomState == ChatRoomState.BEFORE_INIT; + return this.chatRoomState == ChatRoomState.ALIVE; } - public void expire() { - this.chatRoomState = ChatRoomState.COMPLETED; - this.totalSummary = EXPIRED_ROOM_CREATING_SUMMARY_LINE; - this.chatRoomCompletedReason = ChatRoomCompletedReason.EXPIRED; + public boolean isOwner(MemberId memberId) { + return Objects.equals(this.memberId.getValue(), memberId.getValue()); } - public boolean isStarted() { - return this.chatRoomState != ChatRoomState.BEFORE_INIT; + public void softDelete() { + this.chatRoomState = ChatRoomState.DELETED; } - public boolean isOwner(MemberId memberId) { - return Objects.equals(this.memberId.getValue(), memberId.getValue()); + // 기존 보고서가 있는지 확인 + public boolean hasReport() { + return this.chatRoomState == ChatRoomState.COMPLETED + && this.totalSummary != null; } } diff --git a/src/main/java/makeus/cmc/malmo/domain/model/chat/Prompt.java b/src/main/java/makeus/cmc/malmo/domain/model/chat/Prompt.java index f1618fc5..225d032d 100644 --- a/src/main/java/makeus/cmc/malmo/domain/model/chat/Prompt.java +++ b/src/main/java/makeus/cmc/malmo/domain/model/chat/Prompt.java @@ -21,6 +21,7 @@ public class Prompt extends BaseTimeEntity { private boolean isForTotalSummary; private boolean isForGuideline; private boolean isForAnswerMetadata; + private boolean isForTitleGeneration; // BaseTimeEntity fields private LocalDateTime createdAt; @@ -30,6 +31,7 @@ public class Prompt extends BaseTimeEntity { public static Prompt from(Long id, int level, String content, boolean isForSystem, boolean isForSummary, boolean isForCompletedResponse, boolean isForTotalSummary, boolean isForGuideline, boolean isForAnswerMetadata, + boolean isForTitleGeneration, LocalDateTime createdAt, LocalDateTime modifiedAt, LocalDateTime deletedAt) { return Prompt.builder() .id(id) @@ -41,6 +43,7 @@ public static Prompt from(Long id, int level, String content, .isForTotalSummary(isForTotalSummary) .isForGuideline(isForGuideline) .isForAnswerMetadata(isForAnswerMetadata) + .isForTitleGeneration(isForTitleGeneration) .createdAt(createdAt) .modifiedAt(modifiedAt) .deletedAt(deletedAt) diff --git a/src/main/java/makeus/cmc/malmo/domain/service/ChatRoomDomainService.java b/src/main/java/makeus/cmc/malmo/domain/service/ChatRoomDomainService.java index 7faf8b44..aadc4036 100644 --- a/src/main/java/makeus/cmc/malmo/domain/service/ChatRoomDomainService.java +++ b/src/main/java/makeus/cmc/malmo/domain/service/ChatRoomDomainService.java @@ -6,8 +6,6 @@ import makeus.cmc.malmo.domain.value.id.MemberId; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; - @Component public class ChatRoomDomainService { @@ -22,12 +20,4 @@ public ChatMessage createUserMessage(ChatRoomId chatRoomId, int level, int detai public ChatMessage createAiMessage(ChatRoomId chatRoomId, int level, int detailedLevel, String content) { return ChatMessage.createAssistantTextMessage(chatRoomId, level, detailedLevel, content); } - - public boolean isChatRoomExpired(LocalDateTime lastMessageSentTime) { - if (lastMessageSentTime == null) { - return false; - } - - return lastMessageSentTime.isBefore(LocalDateTime.now().minusDays(1)); - } } diff --git a/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomCompletedReason.java b/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomCompletedReason.java index bb71a2f2..b29b26d2 100644 --- a/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomCompletedReason.java +++ b/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomCompletedReason.java @@ -1,5 +1,5 @@ package makeus.cmc.malmo.domain.value.state; public enum ChatRoomCompletedReason { - EXPIRED, COMPLETED_BY_USER, CHAT_PROCESS_DONE + COMPLETED_BY_USER, EXPIRED } diff --git a/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java b/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java index 0786c11c..783729a5 100644 --- a/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java +++ b/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java @@ -1,5 +1,7 @@ package makeus.cmc.malmo.domain.value.state; public enum ChatRoomState { - BEFORE_INIT, ALIVE, PAUSED, NEED_NEXT_QUESTION, COMPLETED, DELETED + ALIVE, // 진행 중 (생성 즉시 ALIVE) + COMPLETED, // 기존 완료된 채팅방 (보고서 조회용) + DELETED // 삭제됨 (soft delete) } \ No newline at end of file diff --git a/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java b/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java index 32c664eb..46ee9898 100644 --- a/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java +++ b/src/main/java/makeus/cmc/malmo/util/GlobalConstants.java @@ -10,10 +10,6 @@ public class GlobalConstants { "나와의 대화를 마무리하고 싶다면 종료하기 버튼을 눌러줘! 대화 종료 후에는 대화 요약 리포트를 보여줄게.\n" + "오늘은 어떤 고민 때문에 나를 찾아왔어?"; - public static final String EXPIRED_ROOM_CREATING_SUMMARY_LINE = "하루가 지나 채팅방이 만료되었습니다. 요약 생성 중..."; - - public static final String COMPLETED_ROOM_CREATING_SUMMARY_LINE = "채팅방이 종료되었습니다. 요약 생성 중..."; - public static final String OPENAI_CHAT_URL = "https://api.openai.com/v1"; public static final String OPENAI_STATUS_URL = "https://status.openai.com/api/v2/status.json"; diff --git a/src/main/resources/data-test.sql b/src/main/resources/data-test.sql index 20029730..e41d5144 100644 --- a/src/main/resources/data-test.sql +++ b/src/main/resources/data-test.sql @@ -11,12 +11,12 @@ VALUES ('지금 연애를 시작하게 된 계기는 무엇인가요?', '지금 ('연애 중 가장 고마웠던 순간은 어떤 상황이었나요?', '연애 중 가장 고마웠던 순간은 어떤 상황이었나요?', 4), ('연인이 서운한 마음을 표현할 때, 나는 어떤 마음이 드나요?', '연인이 서운한 마음을 표현할 때, 나는 어떤 마음이 드나요?', 5); -INSERT INTO prompt_entity (level, content, is_for_answer_metadata, is_for_completed_response, is_for_guideline, is_for_summary, is_for_system, is_for_total_summary) +INSERT INTO prompt_entity (level, content, is_for_answer_metadata, is_for_completed_response, is_for_guideline, is_for_summary, is_for_system, is_for_total_summary, is_for_title_generation) VALUES - (-3, '요약용 프롬프트', true, false, false, true, false, true), - (-2, '시스템 프롬프트' , false, false, false, false, true, false), - (-1, '중간 요약용 프롬프트', true, false, false, true, false, false), - (1, '1단계 프롬프트', false, true, true, false, false, false), - (2, '2단계 프롬프트', false, true, true, false, false, false), - (3, '3단계 프롬프트', false, true, true, false, false, false), - (4, '마지막 프롬프트', false, true, true, false, false, false); + (-3, '요약용 프롬프트', true, false, false, true, false, true, false), + (-2, '시스템 프롬프트' , false, false, false, false, true, false, false), + (-1, '중간 요약용 프롬프트', true, false, false, true, false, false, false), + (0, '다음 대화 내용을 바탕으로 20자 이내의 간결한 제목을 생성해주세요.', false, false, false, false, false, false, true), + (1, '1단계 프롬프트', false, false, true, false, false, false, false), + (2, '2단계 프롬프트', false, false, true, false, false, false, false), + (3, '3단계 프롬프트', false, false, true, false, false, false, false); diff --git a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java index e40987ce..fbe9763c 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityManager; -import makeus.cmc.malmo.adaptor.message.StreamMessage; import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageEntity; import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageSummaryEntity; import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatRoomEntity; @@ -19,13 +18,11 @@ import makeus.cmc.malmo.domain.value.type.Provider; import makeus.cmc.malmo.domain.value.type.SenderType; import makeus.cmc.malmo.integration_test.dto_factory.ChatRoomRequestDtoFactory; -import makeus.cmc.malmo.util.GlobalConstants; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -37,13 +34,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; import static makeus.cmc.malmo.adaptor.in.exception.ErrorCode.*; import static makeus.cmc.malmo.util.GlobalConstants.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -92,10 +85,10 @@ void setup() { private MemberEntity createAndSaveMember(String nickname, String email, String inviteCode) { MemberEntity memberEntity = MemberEntity.builder() .provider(Provider.KAKAO) - .providerId(email) // providerId를 email로 사용 + .providerId(email) .memberRole(MemberRole.MEMBER) .memberState(MemberState.ALIVE) - .startLoveDate(LocalDate.of(2023, 1, 1)) // 임의의 연애 시작일 + .startLoveDate(LocalDate.of(2023, 1, 1)) .nickname(nickname) .email(email) .inviteCodeEntityValue(InviteCodeEntityValue.of(inviteCode)) @@ -111,7 +104,7 @@ private MemberEntity createAndSaveDeletedMember(String nickname, String email, S .memberRole(MemberRole.MEMBER) .memberState(MemberState.DELETED) .nickname(nickname) - .startLoveDate(LocalDate.of(2023, 1, 1)) // 임의의 연애 시작일 + .startLoveDate(LocalDate.of(2023, 1, 1)) .email(email) .inviteCodeEntityValue(InviteCodeEntityValue.of(inviteCode)) .build(); @@ -120,102 +113,60 @@ private MemberEntity createAndSaveDeletedMember(String nickname, String email, S } @Nested - @DisplayName("현재 채팅방 상태 조회") - class GetCurrentChatRoom { + @DisplayName("채팅방 생성") + class CreateChatRoom { @Test - @DisplayName("채팅방이 없는 경우 채팅방 상태 조회에 성공하며, 새로운 채팅방이 생성된다") - void 채팅방_없는_경우_상태_조회_성공() throws Exception { + @DisplayName("채팅방 생성에 성공한다") + void 채팅방_생성_성공() throws Exception { // when & then - mockMvc.perform(get("/chatrooms/current") + mockMvc.perform(post("/chatrooms") .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.BEFORE_INIT.name())); + .andExpect(jsonPath("$.data.chatRoomId").exists()) + .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.ALIVE.name())); - ChatRoomEntity chatRoom = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId", ChatRoomEntity.class) + List chatRooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId", ChatRoomEntity.class) .setParameter("memberId", member.getId()) - .getSingleResult(); - Assertions.assertThat(chatRoom).isNotNull(); - Assertions.assertThat(chatRoom.getChatRoomState()).isEqualTo(ChatRoomState.BEFORE_INIT); + .getResultList(); + Assertions.assertThat(chatRooms).hasSize(1); + Assertions.assertThat(chatRooms.get(0).getChatRoomState()).isEqualTo(ChatRoomState.ALIVE); List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId", ChatMessageEntity.class) - .setParameter("chatRoomId", chatRoom.getId()) + .setParameter("chatRoomId", chatRooms.get(0).getId()) .getResultList(); - Assertions.assertThat(messages).hasSize(2); - String contentCombined = messages.get(0).getContent() + messages.get(1).getContent(); - Assertions.assertThat(contentCombined).isEqualTo(member.getNickname() + "아" + INIT_CHAT_MESSAGE); + Assertions.assertThat(messages).hasSize(1); + Assertions.assertThat(messages.get(0).getContent()).contains(INIT_CHAT_MESSAGE); } @Test - @DisplayName("채팅방이 있는 경우 채팅방 상태 조회에 성공한다") - void 채팅방_있는_경우_상태_조회_성공() throws Exception { - // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder() - .memberEntityId(MemberEntityId.of(member.getId())) - .chatRoomState(ChatRoomState.ALIVE) - .build(); - em.persist(chatRoom); - em.flush(); - + @DisplayName("탈퇴한 사용자의 경우 채팅방 생성에 실패한다") + void 탈퇴한_사용자_생성_실패() throws Exception { // when & then - mockMvc.perform(get("/chatrooms/current") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.ALIVE.name())); + mockMvc.perform(post("/chatrooms") + .header("Authorization", "Bearer " + generateTokenPort.generateToken(deletedMember.getId(), deletedMember.getMemberRole()).getAccessToken())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(NO_SUCH_MEMBER.getCode())); } @Test - @DisplayName("마지막 채팅 시간으로부터 24시간이 지난 경우 채팅방 상태 조회에 성공하며, 새로운 채팅방이 생성된다") - void 마지막_채팅_시간_24시간_지난_경우_상태_조회_성공() throws Exception { - // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder() - .memberEntityId(MemberEntityId.of(member.getId())) - .chatRoomState(ChatRoomState.ALIVE) - .lastMessageSentTime(LocalDateTime.now().minusDays(1).minusHours(1)) // 25시간 전 - .build(); - em.persist(chatRoom); - em.flush(); - - ChatProcessor.CounselingSummary mockSummary = new ChatProcessor.CounselingSummary( - "만료된 채팅방 요약", - "상황 키워드", - "솔루션 키워드", - "재회 고민" - ); - when(chatProcessor.requestTotalSummary(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(mockSummary)); - - // when & then - mockMvc.perform(get("/chatrooms/current") + @DisplayName("여러 개의 채팅방을 생성할 수 있다") + void 다중_채팅방_생성_성공() throws Exception { + // when - 첫 번째 채팅방 생성 + mockMvc.perform(post("/chatrooms") .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.BEFORE_INIT.name())); + .andExpect(status().isOk()); - Assertions.assertThat(chatRoom.getChatRoomState()).isEqualTo(ChatRoomState.COMPLETED); - Assertions.assertThat(chatRoom.getTotalSummary()).isEqualTo(GlobalConstants.EXPIRED_ROOM_CREATING_SUMMARY_LINE); + // when - 두 번째 채팅방 생성 + mockMvc.perform(post("/chatrooms") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); - ChatRoomEntity newChatRoom = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId " + - "AND c.chatRoomState = :chatRoomState", ChatRoomEntity.class) + // then + List chatRooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId AND c.chatRoomState = :state", ChatRoomEntity.class) .setParameter("memberId", member.getId()) - .setParameter("chatRoomState", ChatRoomState.BEFORE_INIT) - .getSingleResult(); - Assertions.assertThat(newChatRoom).isNotNull(); - - List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId", ChatMessageEntity.class) - .setParameter("chatRoomId", newChatRoom.getId()) + .setParameter("state", ChatRoomState.ALIVE) .getResultList(); - - Assertions.assertThat(messages).hasSize(2); - String contentCombined = messages.get(0).getContent() + messages.get(1).getContent(); - Assertions.assertThat(contentCombined).isEqualTo(member.getNickname() + "아" + INIT_CHAT_MESSAGE); - } - - @Test - @DisplayName("탈퇴한 사용자의 경우 채팅방 상태 조회에 실패한다") - void 탈퇴한_사용자_상태_조회_실패() throws Exception { - // when & then - mockMvc.perform(get("/chatrooms/current") - .header("Authorization", "Bearer " + generateTokenPort.generateToken(deletedMember.getId(), deletedMember.getMemberRole()).getAccessToken())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_MEMBER.getCode())); + Assertions.assertThat(chatRooms).hasSize(2); } } @@ -224,242 +175,160 @@ class GetCurrentChatRoom { class SendChatMessage { @Test - @DisplayName("채팅방이 있는 경우 채팅 전송에 성공한다") - void 채팅방_있는_경우_채팅_전송_성공() throws Exception { + @DisplayName("채팅방에 메시지 전송에 성공한다") + void 메시지_전송_성공() throws Exception { // given ChatRoomEntity chatRoom = ChatRoomEntity.builder() .memberEntityId(MemberEntityId.of(member.getId())) .chatRoomState(ChatRoomState.ALIVE) .level(1) + .detailedLevel(1) .build(); em.persist(chatRoom); em.flush(); String message = "안녕하세요"; - // Mock GptService - doAnswer(invocation -> { - Consumer onComplete = invocation.getArgument(4); - onComplete.accept("AI 응답입니다."); - return null; - }).when(chatProcessor).streamChat(any(), any(), any(), any(), any(), any(), any()); - // when & then - mockMvc.perform(post("/chatrooms/current/send") + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto(message)))) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.messageId").exists()); List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId ORDER BY m.createdAt ASC", ChatMessageEntity.class) .setParameter("chatRoomId", chatRoom.getId()) .getResultList(); - Assertions.assertThat(messages).hasSize(2); + Assertions.assertThat(messages).hasSize(1); Assertions.assertThat(messages.get(0).getContent()).isEqualTo(message); Assertions.assertThat(messages.get(0).getSenderType()).isEqualTo(SenderType.USER); } @Test - @DisplayName("채팅방이 없는 경우 채팅 전송에 실패한다") - void 채팅방_없는_경우_채팅_전송_실패() throws Exception { + @DisplayName("다른 사용자의 채팅방에 메시지 전송에 실패한다") + void 권한_없는_채팅방_메시지_전송_실패() throws Exception { + // given + ChatRoomEntity otherChatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(otherMember.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .level(1) + .detailedLevel(1) + .build(); + em.persist(otherChatRoom); + em.flush(); + // when & then - mockMvc.perform(post("/chatrooms/current/send") + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", otherChatRoom.getId()) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto("hi")))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_CHAT_ROOM.getCode())); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(MEMBER_ACCESS_DENIED.getCode())); } @Test - @DisplayName("탈퇴한 사용자의 경우 채팅 전송에 실패한다") - void 탈퇴한_사용자_채팅_전송_실패() throws Exception { + @DisplayName("존재하지 않는 채팅방에 메시지 전송에 실패한다") + void 존재하지_않는_채팅방_메시지_전송_실패() throws Exception { // when & then - mockMvc.perform(post("/chatrooms/current/send") - .header("Authorization", "Bearer " + generateTokenPort.generateToken(deletedMember.getId(), deletedMember.getMemberRole()).getAccessToken()) + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", 999L) + .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto("hi")))) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_MEMBER.getCode())); + .andExpect(jsonPath("$.code").value(NO_SUCH_CHAT_ROOM.getCode())); } @Test - @DisplayName("채팅방이 시작 단계인 경우 채팅 전송에 성공한다") - void 채팅방_시작_단계_채팅_전송_성공() throws Exception { - // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder() - .memberEntityId(MemberEntityId.of(member.getId())) - .chatRoomState(ChatRoomState.ALIVE) - .level(INIT_CHATROOM_LEVEL) - .build(); - em.persist(chatRoom); - em.flush(); - - String message = "시작 메시지"; - - doAnswer(invocation -> { - Consumer onComplete = invocation.getArgument(4); - onComplete.accept("AI 응답입니다."); - return null; - }).when(chatProcessor).streamChat(any(), any(), any(), any(), any(), any(), any()); - + @DisplayName("탈퇴한 사용자의 경우 메시지 전송에 실패한다") + void 탈퇴한_사용자_메시지_전송_실패() throws Exception { // when & then - mockMvc.perform(post("/chatrooms/current/send") - .header("Authorization", "Bearer " + accessToken) + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", 1L) + .header("Authorization", "Bearer " + generateTokenPort.generateToken(deletedMember.getId(), deletedMember.getMemberRole()).getAccessToken()) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto(message)))) - .andExpect(status().isOk()); - - ChatRoomEntity updatedChatRoom = em.find(ChatRoomEntity.class, chatRoom.getId()); - Assertions.assertThat(updatedChatRoom.getChatRoomState()).isEqualTo(ChatRoomState.ALIVE); + .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto("hi")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(NO_SUCH_MEMBER.getCode())); } @Test - @DisplayName("채팅방이 마지막 단계인 경우 채팅 전송에 성공한다") - void 채팅방_마지막_단계_채팅_전송_성공() throws Exception { + @DisplayName("DELETED 상태의 채팅방에 메시지 전송에 실패한다") + void 삭제된_채팅방_메시지_전송_실패() throws Exception { // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder() + ChatRoomEntity deletedChatRoom = ChatRoomEntity.builder() .memberEntityId(MemberEntityId.of(member.getId())) - .chatRoomState(ChatRoomState.ALIVE) - .level(4) // 마지막 단계 레벨 (하드코딩 대신 실제 값 사용) + .chatRoomState(ChatRoomState.DELETED) + .level(1) + .detailedLevel(1) .build(); - em.persist(chatRoom); + em.persist(deletedChatRoom); em.flush(); - String message = "마지막 메시지"; - // when & then - mockMvc.perform(post("/chatrooms/current/send") + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", deletedChatRoom.getId()) .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto(message)))) - .andExpect(status().isOk()); - - List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId ORDER BY m.createdAt ASC", ChatMessageEntity.class) - .setParameter("chatRoomId", chatRoom.getId()) - .getResultList(); - - Assertions.assertThat(messages).hasSize(2); - Assertions.assertThat(messages.get(0).getContent()).isEqualTo(message); - Assertions.assertThat(messages.get(0).getSenderType()).isEqualTo(SenderType.USER); - Assertions.assertThat(messages.get(1).getContent()).isEqualTo("마지막 프롬프트"); - Assertions.assertThat(messages.get(1).getSenderType()).isEqualTo(SenderType.ASSISTANT); + .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto("hi")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(NOT_VALID_CHAT_ROOM.getCode())); } } @Nested - @DisplayName("현재 채팅방 메시지 조회") - class GetCurrentChatRoomMessages { + @DisplayName("채팅방 리스트 조회") + class GetChatRoomList { @Test - @DisplayName("현재 채팅방 메시지 조회에 성공하고 bookmarkId가 포함되지 않는다") - void 현재_채팅방_메시지_조회_성공_bookmarkId_없음() throws Exception { + @DisplayName("채팅방 리스트 조회에 성공한다") + void 채팅방_리스트_조회_성공() throws Exception { // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.ALIVE).build(); - em.persist(chatRoom); - em.persist(ChatMessageEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).level(1).senderType(SenderType.USER).content("메시지1").createdAt(LocalDateTime.now().minusMinutes(2)).build()); - em.persist(ChatMessageEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).level(1).senderType(SenderType.ASSISTANT).content("메시지2").createdAt(LocalDateTime.now().minusMinutes(1)).build()); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .title("첫 번째 채팅방") + .lastMessageSentTime(LocalDateTime.now().minusHours(2)) + .build()); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .title("두 번째 채팅방") + .lastMessageSentTime(LocalDateTime.now().minusHours(1)) + .build()); em.flush(); // when & then - mockMvc.perform(get("/chatrooms/current/messages") + mockMvc.perform(get("/chatrooms") .header("Authorization", "Bearer " + accessToken) .param("page", "0").param("size", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.totalCount").value(2)) - .andExpect(jsonPath("$.data.list[0].content").value("메시지2")) // 최신순 - .andExpect(jsonPath("$.data.list[1].content").value("메시지1")) - .andExpect(jsonPath("$.data.list[0].bookmarkId").doesNotExist()) - .andExpect(jsonPath("$.data.list[1].bookmarkId").doesNotExist()); - } - - @Test - @DisplayName("탈퇴한 사용자의 경우 현재 채팅방 메시지 조회에 실패한다") - void 탈퇴한_사용자_메시지_조회_실패() throws Exception { - // when & then - mockMvc.perform(get("/chatrooms/current/messages") - .header("Authorization", "Bearer " + generateTokenPort.generateToken(deletedMember.getId(), deletedMember.getMemberRole()).getAccessToken())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_MEMBER.getCode())); - } - - @Test - @DisplayName("채팅방이 없는 경우 현재 채팅방 메시지 조회에 실패한다") - void 채팅방_없는_경우_메시지_조회_실패() throws Exception { - // when & then - mockMvc.perform(get("/chatrooms/current/messages") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_CHAT_ROOM.getCode())); + .andExpect(jsonPath("$.data.list[0].title").value("두 번째 채팅방")) + .andExpect(jsonPath("$.data.list[1].title").value("첫 번째 채팅방")); } - } - @Nested - @DisplayName("채팅방 종료") - class CompleteChatRoom { @Test - @DisplayName("채팅방 종료에 성공한다") - void 채팅방_종료_성공() throws Exception { - // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.ALIVE).level(5).build(); - em.persist(chatRoom); - em.persist(ChatMessageSummaryEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).content("요약1").level(1).build()); - em.persist(ChatMessageSummaryEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).content("요약2").level(2).build()); - em.flush(); - - ChatProcessor.CounselingSummary summary = new ChatProcessor.CounselingSummary("최종 요약", "상황 키워드", "솔루션 키워드", "재회 고민"); - when(chatProcessor.requestTotalSummary(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(summary)); - - // when & then - mockMvc.perform(post("/chatrooms/current/complete") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isOk()); - - ChatRoomEntity completedChatRoom = em.find(ChatRoomEntity.class, chatRoom.getId()); - Assertions.assertThat(completedChatRoom.getChatRoomState()).isEqualTo(ChatRoomState.COMPLETED); - verify(outboxHelper, times(1)).publish(any(), any()); - } - - @Test - @DisplayName("채팅방이 없는 경우 채팅방 종료에 실패한다") - void 채팅방_없는_경우_종료_실패() throws Exception { - // when & then - mockMvc.perform(post("/chatrooms/current/complete") - .header("Authorization", "Bearer " + accessToken)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_CHAT_ROOM.getCode())); - } - - @Test - @DisplayName("탈퇴한 사용자의 경우 채팅방 종료에 실패한다") - void 탈퇴한_사용자_종료_실패() throws Exception { - // when & then - mockMvc.perform(post("/chatrooms/current/complete") - .header("Authorization", "Bearer " + generateTokenPort.generateToken(deletedMember.getId(), deletedMember.getMemberRole()).getAccessToken())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(NO_SUCH_MEMBER.getCode())); - } - } - - @Nested - @DisplayName("채팅방 리스트 조회") - class GetChatRoomList { - @Test - @DisplayName("채팅방 리스트 조회에 성공한다") - void 채팅방_리스트_조회_성공() throws Exception { + @DisplayName("ALIVE와 COMPLETED 상태 모두 조회된다") + void ALIVE_COMPLETED_모두_조회() throws Exception { // given - em.persist(ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).totalSummary("요약1").build()); - em.persist(ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).totalSummary("요약2").build()); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .title("진행 중인 채팅방") + .lastMessageSentTime(LocalDateTime.now()) + .build()); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .totalSummary("요약") + .lastMessageSentTime(LocalDateTime.now().minusHours(1)) + .build()); em.flush(); // when & then mockMvc.perform(get("/chatrooms") - .header("Authorization", "Bearer " + accessToken) - .param("page", "0").param("size", "10")) + .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.totalCount").value(2)) - .andExpect(jsonPath("$.data.list[0].totalSummary").value("요약2")) // 최신순 - .andExpect(jsonPath("$.data.list[1].totalSummary").value("요약1")); + .andExpect(jsonPath("$.data.totalCount").value(2)); } @Test @@ -484,19 +353,55 @@ class GetChatRoomList { } @Test - @DisplayName("삭제한 채팅방이 있는 경우 채팅방 리스트 조회에 성공한다") - void 삭제한_채팅방_있는_경우_리스트_조회_성공() throws Exception { + @DisplayName("삭제한 채팅방은 리스트에 조회되지 않는다") + void 삭제한_채팅방_제외_리스트_조회() throws Exception { // given - em.persist(ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).build()); - ChatRoomEntity deletedChatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.DELETED).build(); - em.persist(deletedChatRoom); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .title("활성 채팅방") + .lastMessageSentTime(LocalDateTime.now()) + .build()); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.DELETED) + .title("삭제된 채팅방") + .lastMessageSentTime(LocalDateTime.now()) + .build()); em.flush(); // when & then mockMvc.perform(get("/chatrooms") .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.totalCount").value(1)); // DELETED는 조회되지 않음 + .andExpect(jsonPath("$.data.totalCount").value(1)); + } + + @Test + @DisplayName("키워드로 채팅방을 검색할 수 있다") + void 키워드_검색_성공() throws Exception { + // given + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .title("연애 고민") + .lastMessageSentTime(LocalDateTime.now()) + .build()); + em.persist(ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .title("직장 고민") + .lastMessageSentTime(LocalDateTime.now()) + .build()); + em.flush(); + + // when & then + mockMvc.perform(get("/chatrooms") + .header("Authorization", "Bearer " + accessToken) + .param("keyword", "연애")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.list[0].title").value("연애 고민")); } } @@ -507,7 +412,10 @@ class DeleteChatRoom { @DisplayName("채팅방 한 건 삭제에 성공한다") void 채팅방_한건_삭제_성공() throws Exception { // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); + ChatRoomEntity chatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .build(); em.persist(chatRoom); em.flush(); em.clear(); @@ -527,8 +435,14 @@ class DeleteChatRoom { @DisplayName("채팅방 여러 건 삭제에 성공한다") void 채팅방_여러건_삭제_성공() throws Exception { // given - ChatRoomEntity chatRoom1 = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); - ChatRoomEntity chatRoom2 = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); + ChatRoomEntity chatRoom1 = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .build(); + ChatRoomEntity chatRoom2 = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .build(); em.persist(chatRoom1); em.persist(chatRoom2); em.flush(); @@ -549,7 +463,10 @@ class DeleteChatRoom { @DisplayName("접근 권한이 없으면 채팅방 삭제에 실패한다") void 접근_권한_없으면_삭제_실패() throws Exception { // given - ChatRoomEntity otherChatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(otherMember.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); + ChatRoomEntity otherChatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(otherMember.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .build(); em.persist(otherChatRoom); em.flush(); @@ -582,10 +499,25 @@ class GetChatRoomMessages { @DisplayName("채팅방의 메시지 리스트 조회에 성공한다") void 메시지_리스트_조회_성공() throws Exception { // given - ChatRoomEntity chatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(member.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); + ChatRoomEntity chatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .build(); em.persist(chatRoom); - em.persist(ChatMessageEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).level(1).senderType(SenderType.USER).content("메시지1").createdAt(LocalDateTime.now().minusMinutes(2)).build()); - em.persist(ChatMessageEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).level(1).senderType(SenderType.ASSISTANT).content("메시지2").createdAt(LocalDateTime.now().minusMinutes(1)).build()); + em.persist(ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .level(1) + .senderType(SenderType.USER) + .content("메시지1") + .createdAt(LocalDateTime.now().minusMinutes(2)) + .build()); + em.persist(ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .level(1) + .senderType(SenderType.ASSISTANT) + .content("메시지2") + .createdAt(LocalDateTime.now().minusMinutes(1)) + .build()); em.flush(); // when & then @@ -594,7 +526,7 @@ class GetChatRoomMessages { .param("page", "0").param("size", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.totalCount").value(2)) - .andExpect(jsonPath("$.data.list[0].content").value("메시지1")) // 오래된 순 + .andExpect(jsonPath("$.data.list[0].content").value("메시지1")) .andExpect(jsonPath("$.data.list[1].content").value("메시지2")); } @@ -612,7 +544,10 @@ class GetChatRoomMessages { @DisplayName("채팅방 접근 권한이 없는 경우 채팅방의 메시지 리스트 조회에 실패한다") void 접근_권한_없는_경우_메시지_리스트_조회_실패() throws Exception { // given - ChatRoomEntity otherChatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(otherMember.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); + ChatRoomEntity otherChatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(otherMember.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .build(); em.persist(otherChatRoom); em.flush(); @@ -647,9 +582,21 @@ class GetChatRoomSummary { .totalSummary("전체 요약") .build(); em.persist(chatRoom); - em.persist(ChatMessageSummaryEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).content("요약1").level(1).build()); - em.persist(ChatMessageSummaryEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).content("요약2").level(2).build()); - em.persist(ChatMessageSummaryEntity.builder().chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())).content("요약3").level(3).build()); + em.persist(ChatMessageSummaryEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("요약1") + .level(1) + .build()); + em.persist(ChatMessageSummaryEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("요약2") + .level(2) + .build()); + em.persist(ChatMessageSummaryEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("요약3") + .level(3) + .build()); em.flush(); // when & then @@ -676,7 +623,10 @@ class GetChatRoomSummary { @DisplayName("채팅방 접근 권한이 없는 경우 채팅방 요약 조회에 실패한다") void 접근_권한_없는_경우_요약_조회_실패() throws Exception { // given - ChatRoomEntity otherChatRoom = ChatRoomEntity.builder().memberEntityId(MemberEntityId.of(otherMember.getId())).chatRoomState(ChatRoomState.COMPLETED).build(); + ChatRoomEntity otherChatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(otherMember.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .build(); em.persist(otherChatRoom); em.flush(); @@ -698,4 +648,3 @@ class GetChatRoomSummary { } } } - diff --git a/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/ChatRoomRequestDtoFactory.java b/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/ChatRoomRequestDtoFactory.java index 349ae267..81506687 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/ChatRoomRequestDtoFactory.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/ChatRoomRequestDtoFactory.java @@ -1,14 +1,13 @@ package makeus.cmc.malmo.integration_test.dto_factory; import makeus.cmc.malmo.adaptor.in.web.controller.ChatRoomController; -import makeus.cmc.malmo.adaptor.in.web.controller.CurrentChatController; import java.util.List; public class ChatRoomRequestDtoFactory { - public static CurrentChatController.ChatRequest createSendChatMessageRequestDto(String message) { - return new CurrentChatController.ChatRequest(message); + public static ChatRoomController.SendMessageRequest createSendChatMessageRequestDto(String message) { + return new ChatRoomController.SendMessageRequest(message); } public static ChatRoomController.DeleteChatRoomRequestDto createDeleteChatRoomsRequestDto(List chatRoomIds) { diff --git a/src/test/java/makeus/cmc/malmo/mapper/ChatRoomMapperTest.java b/src/test/java/makeus/cmc/malmo/mapper/ChatRoomMapperTest.java index 1352d0cd..18ada605 100644 --- a/src/test/java/makeus/cmc/malmo/mapper/ChatRoomMapperTest.java +++ b/src/test/java/makeus/cmc/malmo/mapper/ChatRoomMapperTest.java @@ -35,6 +35,7 @@ void toDomain() { .level(1) .detailedLevel(2) .lastMessageSentTime(now) + .title("테스트 제목") .totalSummary("total summary") .situationKeyword("situation") .solutionKeyword("solution") @@ -55,6 +56,7 @@ void toDomain() { assertThat(domain.getLevel()).isEqualTo(entity.getLevel()); assertThat(domain.getDetailedLevel()).isEqualTo(entity.getDetailedLevel()); assertThat(domain.getLastMessageSentTime()).isEqualTo(entity.getLastMessageSentTime()); + assertThat(domain.getTitle()).isEqualTo(entity.getTitle()); assertThat(domain.getTotalSummary()).isEqualTo(entity.getTotalSummary()); assertThat(domain.getSituationKeyword()).isEqualTo(entity.getSituationKeyword()); assertThat(domain.getSolutionKeyword()).isEqualTo(entity.getSolutionKeyword()); @@ -77,6 +79,7 @@ void toEntity() { 1, 2, now, + "테스트 제목", "total summary", "situation", "solution", @@ -97,6 +100,7 @@ void toEntity() { assertThat(entity.getLevel()).isEqualTo(domain.getLevel()); assertThat(entity.getDetailedLevel()).isEqualTo(domain.getDetailedLevel()); assertThat(entity.getLastMessageSentTime()).isEqualTo(domain.getLastMessageSentTime()); + assertThat(entity.getTitle()).isEqualTo(domain.getTitle()); assertThat(entity.getTotalSummary()).isEqualTo(domain.getTotalSummary()); assertThat(entity.getSituationKeyword()).isEqualTo(domain.getSituationKeyword()); assertThat(entity.getSolutionKeyword()).isEqualTo(domain.getSolutionKeyword()); diff --git a/src/test/java/makeus/cmc/malmo/mapper/PromptMapperTest.java b/src/test/java/makeus/cmc/malmo/mapper/PromptMapperTest.java index 30bd0755..f335d9a8 100644 --- a/src/test/java/makeus/cmc/malmo/mapper/PromptMapperTest.java +++ b/src/test/java/makeus/cmc/malmo/mapper/PromptMapperTest.java @@ -35,6 +35,7 @@ void toDomain() { .isForTotalSummary(false) .isForGuideline(false) .isForAnswerMetadata(false) + .isForTitleGeneration(false) .createdAt(now) .modifiedAt(now) .deletedAt(null) @@ -53,6 +54,7 @@ void toDomain() { assertThat(domain.isForTotalSummary()).isEqualTo(entity.isForTotalSummary()); assertThat(domain.isForGuideline()).isEqualTo(entity.isForGuideline()); assertThat(domain.isForAnswerMetadata()).isEqualTo(entity.isForAnswerMetadata()); + assertThat(domain.isForTitleGeneration()).isEqualTo(entity.isForTitleGeneration()); assertThat(domain.getCreatedAt()).isEqualTo(entity.getCreatedAt()); assertThat(domain.getModifiedAt()).isEqualTo(entity.getModifiedAt()); assertThat(domain.getDeletedAt()).isEqualTo(entity.getDeletedAt()); @@ -73,6 +75,7 @@ void toEntity() { false, false, false, + false, now, now, null @@ -91,6 +94,7 @@ void toEntity() { assertThat(entity.isForTotalSummary()).isEqualTo(domain.isForTotalSummary()); assertThat(entity.isForGuideline()).isEqualTo(domain.isForGuideline()); assertThat(entity.isForAnswerMetadata()).isEqualTo(domain.isForAnswerMetadata()); + assertThat(entity.isForTitleGeneration()).isEqualTo(domain.isForTitleGeneration()); assertThat(entity.getCreatedAt()).isEqualTo(domain.getCreatedAt()); assertThat(entity.getModifiedAt()).isEqualTo(domain.getModifiedAt()); assertThat(entity.getDeletedAt()).isEqualTo(domain.getDeletedAt()); From c63faa4cc41b61c8004e964dfd24d457f0efa25e Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:15:14 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A7=A5?= =?UTF-8?q?=EB=9D=BD=20=EC=9C=A0=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?4=EB=8B=A8=EA=B3=84=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqls/MM-168.sql | 107 ++++++++++++++++++ .../chat_room/DetailedPromptQueryHelper.java | 4 +- .../helper/chat_room/PromptQueryHelper.java | 8 +- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/sqls/MM-168.sql b/sqls/MM-168.sql index 91fc82ea..c00eec99 100644 --- a/sqls/MM-168.sql +++ b/sqls/MM-168.sql @@ -66,3 +66,110 @@ WHERE chat_room_state = 'COMPLETED' -- WHERE chat_room_state = 'COMPLETED' -- ORDER BY chat_room_id DESC -- LIMIT 10; + +-- 작업 07: 4단계 프롬프트 추가 +-- 4단계는 전체 상담 완료 후 사용자가 이전 맥락을 유지하며 자유롭게 대화하는 단계 + +-- 4단계 가이드라인 프롬프트 추가 +INSERT INTO prompt (level, content, is_for_system, is_for_summary, is_for_completed_response, + is_for_total_summary, is_for_guideline, is_for_answer_metadata, is_for_title_generation) +VALUES (4, '현재는 상담의 [4단계: 자유 대화] 단계야. + +4단계는 1~3단계의 정형화된 상담이 종료된 후, 사용자가 이전 상담 맥락을 바탕으로 추가 질문을 하거나 자유롭게 대화하는 단계야. + +[응답 생성 규칙] +1. 이전 1~3단계에서 나눴던 갈등 상황, 분석 내용, 해결책 등의 맥락을 충분히 활용할 것 +2. 사용자의 질문이나 고민이 이전 상담 내용과 연결되는 경우, 그 맥락을 자연스럽게 언급하며 응답할 것 +3. 새로운 고민이나 질문이 나온 경우에도, 이전 상담에서 파악한 사용자의 애착유형, 연애 가치관, 감정 패턴 등을 고려하여 맞춤형 조언을 제공할 것 +4. 친구처럼 편안하게 대화하되, 연애 상담 전문가로서의 전문성을 잃지 말 것 +5. 사용자가 이전 상담 내용에 대해 추가 질문을 하면, 구체적이고 깊이 있게 답변할 것 +6. 단계가 구분되어 있다는 것을 사용자가 인지하지 못하도록, 자연스럽게 대화를 이어갈 것 + +[주의사항] +- 형식적인 종료나 새로운 시작을 알리는 멘트는 하지 말 것 +- 이전 대화 내용을 요약하거나 반복하는 것보다는, 그 내용을 바탕으로 새로운 인사이트를 제공할 것 +- 사용자가 완전히 새로운 주제를 꺼내더라도, 가능하다면 이전 맥락과 연결지어 일관성 있는 상담을 유지할 것', + false, false, false, false, true, false, false); + +-- 4단계 요약 프롬프트 추가 +INSERT INTO prompt (level, content, is_for_system, is_for_summary, is_for_completed_response, + is_for_total_summary, is_for_guideline, is_for_answer_metadata, is_for_title_generation) +VALUES (4, '대화를 바탕으로 핵심적인 내용을 요약해줘. +지금 전달된 대화는 앞으로 참조하지 않을 것이며, 오로지 너가 요약한 내용만을 참조할거야. +4단계는 자유 대화 단계이므로, 사용자가 새롭게 제기한 질문이나 고민, 그리고 그에 대한 답변의 핵심을 요약할 것. +만약 이전 상담 내용과 연결되는 대화라면, 그 연결점도 명시할 것. +*요약 글자수 제한 150자* + +[필수 규칙] +- "사용자"라는 표현은 절대 쓰지 말고 해당 표현이 필요한 상황에서는 그냥 주어를 생략할 것. +- "Assistant", "OK" 등 일상 생활에서 사용하지 않는 표현은 절대 응답에 넣지 말 것. +- 명사형 전성어미 (-ㅁ/음)을 이용해 마무리할 것.', + false, true, false, false, false, false, false); + +-- 4단계 세부 프롬프트 추가 (DetailedPrompt) +-- 4단계는 단일 세부 단계로 구성 (detailedLevel = 1) + +-- 4단계 가이드라인 상세 프롬프트 +INSERT INTO detailed_prompt (level, detailed_level, content, is_for_validation, is_for_summary, + metadata_title, is_last_detailed_prompt, is_for_guideline) +VALUES (4, 1, '[응답 생성 규칙] +이전 상담(1~3단계)의 맥락을 바탕으로 사용자의 질문이나 고민에 응답할 것: + +1. 사용자 메시지 파악 + - 이전 상담 내용과 관련된 추가 질문인지 확인 + - 완전히 새로운 주제나 고민인지 확인 + +2. 맥락 연결 + - 관련 질문인 경우: 이전 단계의 갈등 분석, 애착유형 분석, 제안한 해결책 등을 자연스럽게 참조 + - 새로운 주제인 경우: 이전에 파악한 사용자의 애착유형, 연애 가치관, 감정 패턴을 고려하여 조언 + +3. 응답 생성 + - 구체적이고 실용적인 조언 제공 + - 필요시 이전 대화 내용과 연결하여 일관성 유지 + - 친구처럼 자연스럽게, 하지만 전문가로서 깊이 있게 답변 + +4. 추가 대화 유도 + - 사용자가 더 궁금한 점이 있는지 자연스럽게 확인 + - 열린 질문으로 대화를 이어갈 수 있도록 유도 + +[주의사항] +- 충분성 조건 검증이 없으므로, 자유롭게 대화하되 깊이 있는 상담 품질 유지 +- 사용자가 만족스러운 답변을 받을 때까지 성의 있게 응답 +- 이전 상담 내용을 단순 반복하지 말고, 새로운 관점이나 구체적인 예시 추가', + false, false, '자유 대화 응답', true, true); + +-- 4단계 검증 프롬프트 (isForValidation) +-- 4단계는 자유 대화이므로 검증 없이 항상 completed: true 반환 +INSERT INTO detailed_prompt (level, detailed_level, content, is_for_validation, is_for_summary, + metadata_title, is_last_detailed_prompt, is_for_guideline) +VALUES (4, 1, '당신은 User와 Assistant의 대화를 읽고 <조건>을 만족하는지 판정하는 검수원입니다. +응답 양식은 <응답 양식>에 따르세요. + +<조건> +4단계는 자유 대화 단계이므로, 별도의 충분성 조건 검증 없이 항상 completed를 true로 반환하세요. +단, Assistant의 응답이 사용자의 질문에 적절하게 답변했는지만 확인하세요. + +<응답 양식> +당신은 JSON으로 응답해야 하며, 세 가지 필드가 존재해야 합니다. +- completed: 4단계는 항상 true +- summary: Assistant가 사용자의 질문에 대해 어떤 조언이나 답변을 제공했는지 간단히 요약하세요. +- advice: 4단계는 항상 null + +[예시] +{ + "completed": "true", + "summary": "이전 상담에서 논의한 회피형 남자친구와의 소통 방법에 대해 추가로 구체적인 대화 예시를 제공함", + "advice": null +}', + true, false, null, true, false); + +-- 마이그레이션 확인 쿼리 (실행 후 확인용) +-- SELECT level, content, is_for_guideline, is_for_summary +-- FROM prompt +-- WHERE level = 4 +-- ORDER BY prompt_id; +-- +-- SELECT level, detailed_level, is_for_guideline, is_for_validation, metadata_title +-- FROM detailed_prompt +-- WHERE level = 4 +-- ORDER BY detailed_prompt_id; diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java index 1cdc1edb..923bba3d 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/DetailedPromptQueryHelper.java @@ -34,8 +34,8 @@ public DetailedPrompt getGuidelinePromptWithFallback(int level, int detailedLeve .map(detailedPromptMapper::toDomain); if (prompt.isEmpty()) { - // 4단계 이상: 3단계 1번 프롬프트 재사용 - return detailedPromptRepository.findByLevelAndDetailedLevelAndIsForGuidelineTrue(3, 1) + // 5단계 이상: 4단계 1번 프롬프트 재사용 + return detailedPromptRepository.findByLevelAndDetailedLevelAndIsForGuidelineTrue(4, 1) .map(detailedPromptMapper::toDomain) .orElseThrow(() -> new RuntimeException("Fallback prompt not found")); } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java index be857d92..2825e98d 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java @@ -31,14 +31,14 @@ public Prompt getGuidelinePromptWithFallback(int level) { Prompt prompt = loadPromptPort.loadGuidelinePrompt(level).orElse(null); if (prompt == null) { - // 4단계 이상: 3단계 프롬프트 재사용 - return loadPromptPort.loadGuidelinePrompt(3) + // 5단계 이상: 4단계 프롬프트 재사용 + return loadPromptPort.loadGuidelinePrompt(4) .orElseThrow(PromptNotFoundException::new); } - // isForCompletedResponse가 true인 경우도 무시하고 3단계 반환 + // isForCompletedResponse가 true인 경우도 무시하고 4단계 반환 if (prompt.isForCompletedResponse()) { - return loadPromptPort.loadGuidelinePrompt(3) + return loadPromptPort.loadGuidelinePrompt(4) .orElseThrow(PromptNotFoundException::new); } From d5b23d3dd7aa5293ff6e620873e770b26fe935ea Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:48:23 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=204=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=EB=A5=BC=20=EB=B6=84=EB=A6=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqls/MM-168.sql | 26 +--------------- .../service/chat/ChatMessageService.java | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/sqls/MM-168.sql b/sqls/MM-168.sql index c00eec99..15c19c04 100644 --- a/sqls/MM-168.sql +++ b/sqls/MM-168.sql @@ -110,6 +110,7 @@ VALUES (4, '대화를 바탕으로 핵심적인 내용을 요약해줘. -- 4단계는 단일 세부 단계로 구성 (detailedLevel = 1) -- 4단계 가이드라인 상세 프롬프트 +-- 4단계는 충분성 검사를 하지 않으므로 검증 프롬프트는 불필요 INSERT INTO detailed_prompt (level, detailed_level, content, is_for_validation, is_for_summary, metadata_title, is_last_detailed_prompt, is_for_guideline) VALUES (4, 1, '[응답 생성 규칙] @@ -138,31 +139,6 @@ VALUES (4, 1, '[응답 생성 규칙] - 이전 상담 내용을 단순 반복하지 말고, 새로운 관점이나 구체적인 예시 추가', false, false, '자유 대화 응답', true, true); --- 4단계 검증 프롬프트 (isForValidation) --- 4단계는 자유 대화이므로 검증 없이 항상 completed: true 반환 -INSERT INTO detailed_prompt (level, detailed_level, content, is_for_validation, is_for_summary, - metadata_title, is_last_detailed_prompt, is_for_guideline) -VALUES (4, 1, '당신은 User와 Assistant의 대화를 읽고 <조건>을 만족하는지 판정하는 검수원입니다. -응답 양식은 <응답 양식>에 따르세요. - -<조건> -4단계는 자유 대화 단계이므로, 별도의 충분성 조건 검증 없이 항상 completed를 true로 반환하세요. -단, Assistant의 응답이 사용자의 질문에 적절하게 답변했는지만 확인하세요. - -<응답 양식> -당신은 JSON으로 응답해야 하며, 세 가지 필드가 존재해야 합니다. -- completed: 4단계는 항상 true -- summary: Assistant가 사용자의 질문에 대해 어떤 조언이나 답변을 제공했는지 간단히 요약하세요. -- advice: 4단계는 항상 null - -[예시] -{ - "completed": "true", - "summary": "이전 상담에서 논의한 회피형 남자친구와의 소통 방법에 대해 추가로 구체적인 대화 예시를 제공함", - "advice": null -}', - true, false, null, true, false); - -- 마이그레이션 확인 쿼리 (실행 후 확인용) -- SELECT level, content, is_for_guideline, is_for_summary -- FROM prompt diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index 1c0d2a05..bed7cabc 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -66,7 +66,12 @@ public CompletableFuture processStreamChatMessage(ProcessMessageCommand co // 1. 유저 메시지 저장 // saveUserMessage(chatRoom, command); - // 2. 충분성 조건 검사 + // 4단계 이상: 충분성 검사 없이 자유 대화 응답 + if (command.getPromptLevel() >= 4) { + return processFreeConversation(member, chatRoom, command); + } + + // 2. 충분성 조건 검사 (1~3단계) CompletableFuture sufficiencyCheck = requestSufficiencyCheck(member, chatRoom, command); @@ -136,6 +141,29 @@ public CompletableFuture processAnswerMetadata(ProcessAnswerCommand comman } + /** + * 4단계 이상: 자유 대화 처리 + * - 충분성 검사 없이 바로 응답 생성 + * - 단계 전환 없이 현재 level 유지 + * - 메타데이터 저장 스킵 + */ + private CompletableFuture processFreeConversation(Member member, ChatRoom chatRoom, ProcessMessageCommand command) { + List> messages = chatPromptBuilder.createForProcessUserMessage( + member, chatRoom, command.getNowMessage()); + + Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); + Prompt prompt = promptQueryHelper.getGuidelinePromptWithFallback(command.getPromptLevel()); + DetailedPrompt detailedPrompt = detailedPromptQueryHelper.getGuidelinePromptWithFallback( + command.getPromptLevel(), command.getDetailedLevel()); + + return chatProcessor.streamChat(messages, systemPrompt, prompt, detailedPrompt, + chunk -> chatSseSender.sendResponseChunk(MemberId.of(member.getId()), chunk), + fullAnswer -> saveAiMessage(MemberId.of(member.getId()), ChatRoomId.of(chatRoom.getId()), + command.getPromptLevel(), command.getDetailedLevel(), fullAnswer), + errorMessage -> chatSseSender.sendError(MemberId.of(member.getId()), errorMessage) + ).toFuture(); + } + private CompletableFuture requestSufficiencyCheck(Member member, ChatRoom chatRoom, ProcessMessageCommand command) { List> messages = chatPromptBuilder.createForSufficiencyCheck( member, chatRoom, command.getPromptLevel(), command.getDetailedLevel()); From 958070e776e47394c9b782867460303bc003ea12 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:30:20 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20Window=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=9E=84=EA=B3=84=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=EC=9A=94=EC=95=BD=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../malmo/adaptor/in/RedisStreamConsumer.java | 12 +++ .../RequestConversationSummaryMessage.java | 15 ++++ .../adaptor/message/StreamMessageType.java | 3 +- .../ChatMessageSummaryPersistenceAdapter.java | 7 ++ .../adapter/ChatRoomPersistenceAdapter.java | 30 ++++++- .../chat/ChatMessageRepository.java | 8 +- .../chat/ChatMessageSummaryRepository.java | 8 ++ .../chat_room/ChatRoomCommandHelper.java | 7 ++ .../helper/chat_room/ChatRoomQueryHelper.java | 13 ++- .../helper/chat_room/PromptQueryHelper.java | 9 +++ .../port/in/chat/ProcessMessageUseCase.java | 10 +++ .../port/out/chat/LoadMessagesPort.java | 4 + .../port/out/chat/LoadSummarizedMessages.java | 3 + .../out/chat/SaveChatMessageSummaryPort.java | 7 ++ .../service/chat/ChatMessageService.java | 80 ++++++++++++++++++- .../service/chat/ChatProcessor.java | 11 +++ .../service/chat/ChatPromptBuilder.java | 42 ++++++++++ .../cmc/malmo/util/ChatTokenConstants.java | 18 +++++ 18 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 src/main/java/makeus/cmc/malmo/adaptor/message/RequestConversationSummaryMessage.java create mode 100644 src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java create mode 100644 src/main/java/makeus/cmc/malmo/util/ChatTokenConstants.java diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java b/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java index e51ee153..2bd418b0 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/RedisStreamConsumer.java @@ -70,6 +70,9 @@ public void onMessage(MapRecord record) { case REQUEST_TITLE_GENERATION: future = processTitleGeneration(payloadNode); break; + case REQUEST_CONVERSATION_SUMMARY: + future = processConversationSummary(payloadNode); + break; default: log.warn("Unknown message type: {}", type); // 알 수 없는 타입은 바로 ACK 처리 @@ -128,6 +131,15 @@ private CompletableFuture processTitleGeneration(JsonNode payloadNode) { ); } + private CompletableFuture processConversationSummary(JsonNode payloadNode) { + return processMessageUseCase.processConversationSummary( + ProcessMessageUseCase.ProcessConversationSummaryCommand.builder() + .chatRoomId(payloadNode.get("chatRoomId").asLong()) + .level(payloadNode.get("level").asInt()) + .build() + ); + } + private void handleFailedMessage(MapRecord record) { try { // 현재 retry 횟수 확인 diff --git a/src/main/java/makeus/cmc/malmo/adaptor/message/RequestConversationSummaryMessage.java b/src/main/java/makeus/cmc/malmo/adaptor/message/RequestConversationSummaryMessage.java new file mode 100644 index 00000000..960d10bf --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/message/RequestConversationSummaryMessage.java @@ -0,0 +1,15 @@ +package makeus.cmc.malmo.adaptor.message; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) +public class RequestConversationSummaryMessage implements StreamMessage { + private Long chatRoomId; + private Integer level; +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java b/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java index 4d403f8c..6f9bafec 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/message/StreamMessageType.java @@ -3,5 +3,6 @@ public enum StreamMessageType { REQUEST_CHAT_MESSAGE, REQUEST_EXTRACT_METADATA, - REQUEST_TITLE_GENERATION; // 제목 생성 요청 + REQUEST_TITLE_GENERATION, // 제목 생성 요청 + REQUEST_CONVERSATION_SUMMARY; // 4단계 대화 요약 요청 } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java index 2b7e4c81..b5431cf9 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatMessageSummaryPersistenceAdapter.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import java.util.List; +import java.util.Optional; @RequiredArgsConstructor @Component @@ -25,4 +26,10 @@ public List loadSummarizedMessages(ChatRoomId chatRoomId) { .map(chatMessageSummaryMapper::toDomain) .toList(); } + + @Override + public Optional loadLatestSummaryByLevel(ChatRoomId chatRoomId, int level) { + return chatMessageSummaryRepository.findLatestByChatRoomIdAndLevel(chatRoomId.getValue(), level) + .map(chatMessageSummaryMapper::toDomain); + } } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 77377a1b..061833f0 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -2,13 +2,17 @@ import lombok.RequiredArgsConstructor; import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageSummaryEntity; import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatRoomEntity; import makeus.cmc.malmo.adaptor.out.persistence.mapper.ChatMessageMapper; +import makeus.cmc.malmo.adaptor.out.persistence.mapper.ChatMessageSummaryMapper; import makeus.cmc.malmo.adaptor.out.persistence.mapper.ChatRoomMapper; import makeus.cmc.malmo.adaptor.out.persistence.repository.chat.ChatMessageRepository; +import makeus.cmc.malmo.adaptor.out.persistence.repository.chat.ChatMessageSummaryRepository; import makeus.cmc.malmo.adaptor.out.persistence.repository.chat.ChatRoomRepository; import makeus.cmc.malmo.application.port.out.chat.*; import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.model.chat.ChatRoom; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; @@ -23,12 +27,14 @@ @Component @RequiredArgsConstructor public class ChatRoomPersistenceAdapter - implements LoadMessagesPort, SaveChatRoomPort, LoadChatRoomPort, SaveChatMessagePort, DeleteChatRoomPort { + implements LoadMessagesPort, SaveChatRoomPort, LoadChatRoomPort, SaveChatMessagePort, DeleteChatRoomPort, SaveChatMessageSummaryPort { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; + private final ChatMessageSummaryRepository chatMessageSummaryRepository; private final ChatRoomMapper chatRoomMapper; private final ChatMessageMapper chatMessageMapper; + private final ChatMessageSummaryMapper chatMessageSummaryMapper; @Override public Optional loadMessageById(Long messageId) { @@ -62,6 +68,21 @@ public List loadChatRoomLevelAndDetailedLevelMessages(ChatRoomId ch .toList(); } + @Override + public List loadRecentMessagesByLevel(ChatRoomId chatRoomId, int level, int limit) { + List entities = chatMessageRepository.findByChatRoomIdAndLevelOrderByCreatedAtDesc( + chatRoomId.getValue(), level); + return entities.stream() + .limit(limit) + .map(chatMessageMapper::toDomain) + .toList(); + } + + @Override + public long countMessagesByLevel(ChatRoomId chatRoomId, int level) { + return chatMessageRepository.countByChatRoomIdAndLevel(chatRoomId.getValue(), level); + } + @Override public List loadActiveChatRoomsByMemberId(MemberId memberId) { return chatRoomRepository.findActiveChatRoomsByMemberEntityId(memberId.getValue()) @@ -125,4 +146,11 @@ public void deleteChatRooms(List chatRoomIds) { chatRoomIds.stream().map(ChatRoomId::getValue).toList() ); } + + @Override + public ChatMessageSummary saveChatMessageSummary(ChatMessageSummary chatMessageSummary) { + ChatMessageSummaryEntity entity = chatMessageSummaryMapper.toEntity(chatMessageSummary); + ChatMessageSummaryEntity savedEntity = chatMessageSummaryRepository.save(entity); + return chatMessageSummaryMapper.toDomain(savedEntity); + } } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepository.java index dce3deea..29cae069 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepository.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepository.java @@ -3,9 +3,9 @@ import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; -import java.util.Optional; public interface ChatMessageRepository extends JpaRepository, ChatMessageRepositoryCustom { @@ -14,4 +14,10 @@ public interface ChatMessageRepository extends JpaRepository findByChatRoomIdAndLevelAndDetailedLevel(Long chatRoomId, int level, int detailedLevel); + + @Query("SELECT c FROM ChatMessageEntity c WHERE c.chatRoomEntityId.value = :chatRoomId AND c.level = :level ORDER BY c.createdAt DESC") + List findByChatRoomIdAndLevelOrderByCreatedAtDesc(@Param("chatRoomId") Long chatRoomId, @Param("level") int level); + + @Query("SELECT COUNT(c) FROM ChatMessageEntity c WHERE c.chatRoomEntityId.value = :chatRoomId AND c.level = :level") + long countByChatRoomIdAndLevel(@Param("chatRoomId") Long chatRoomId, @Param("level") int level); } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageSummaryRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageSummaryRepository.java index 698e761d..5efca85d 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageSummaryRepository.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageSummaryRepository.java @@ -3,11 +3,19 @@ import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageSummaryEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface ChatMessageSummaryRepository extends JpaRepository { @Query("SELECT c FROM ChatMessageSummaryEntity c WHERE c.chatRoomEntityId.value = :chatRoomId") List findSummarizedMessagesByChatRoomEntityId(Long chatRoomId); + + @Query("SELECT c FROM ChatMessageSummaryEntity c WHERE c.chatRoomEntityId.value = :chatRoomId AND c.level = :level ORDER BY c.createdAt DESC") + List findByChatRoomIdAndLevelOrderByCreatedAtDesc(@Param("chatRoomId") Long chatRoomId, @Param("level") int level); + + @Query("SELECT c FROM ChatMessageSummaryEntity c WHERE c.chatRoomEntityId.value = :chatRoomId AND c.level = :level ORDER BY c.createdAt DESC") + Optional findLatestByChatRoomIdAndLevel(@Param("chatRoomId") Long chatRoomId, @Param("level") int level); } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java index accec09c..3997dc0b 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomCommandHelper.java @@ -3,8 +3,10 @@ import lombok.RequiredArgsConstructor; import makeus.cmc.malmo.application.port.out.chat.DeleteChatRoomPort; import makeus.cmc.malmo.application.port.out.chat.SaveChatMessagePort; +import makeus.cmc.malmo.application.port.out.chat.SaveChatMessageSummaryPort; import makeus.cmc.malmo.application.port.out.chat.SaveChatRoomPort; import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.model.chat.ChatRoom; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import org.springframework.stereotype.Component; @@ -19,6 +21,7 @@ public class ChatRoomCommandHelper { private final DeleteChatRoomPort deleteChatRoomPort; private final SaveChatMessagePort saveChatMessagePort; + private final SaveChatMessageSummaryPort saveChatMessageSummaryPort; public ChatRoom saveChatRoom(ChatRoom chatRoom) { return saveChatRoomPort.saveChatRoom(chatRoom); @@ -35,4 +38,8 @@ public ChatMessage saveChatMessage(ChatMessage chatMessage) { public List saveChatMessages(List chatMessages) { return saveChatMessagePort.saveChatMessages(chatMessages); } + + public ChatMessageSummary saveChatMessageSummary(ChatMessageSummary chatMessageSummary) { + return saveChatMessageSummaryPort.saveChatMessageSummary(chatMessageSummary); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java index ba2a8e50..8823cf5a 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java @@ -12,7 +12,6 @@ import makeus.cmc.malmo.domain.model.chat.ChatMessage; import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.model.chat.ChatRoom; -import makeus.cmc.malmo.domain.model.chat.MemberChatRoomMetadata; import makeus.cmc.malmo.domain.model.member.MemberMemory; import makeus.cmc.malmo.domain.value.id.ChatRoomId; import makeus.cmc.malmo.domain.value.id.MemberId; @@ -110,4 +109,16 @@ public List getChatRoomLevelAndDetailedLevelMessages(ChatRoomId cha public List getMemberMemoriesByMemberId(MemberId memberId) { return loadMemberMemoryPort.loadMemberMemoryByMemberId(memberId); } + + public List getRecentMessages(ChatRoomId chatRoomId, int level, int limit) { + return loadMessagesPort.loadRecentMessagesByLevel(chatRoomId, level, limit); + } + + public long countMessagesByLevel(ChatRoomId chatRoomId, int level) { + return loadMessagesPort.countMessagesByLevel(chatRoomId, level); + } + + public Optional getLatestSummaryByLevel(ChatRoomId chatRoomId, int level) { + return loadSummarizedMessages.loadLatestSummaryByLevel(chatRoomId, level); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java index 2825e98d..379823e7 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/PromptQueryHelper.java @@ -6,6 +6,8 @@ import makeus.cmc.malmo.domain.model.chat.Prompt; import org.springframework.stereotype.Component; +import java.util.Optional; + @Component @RequiredArgsConstructor @@ -57,4 +59,11 @@ public Prompt getTitleGenerationPrompt() { return loadPromptPort.loadTitleGenerationPrompt() .orElseThrow(() -> new RuntimeException("Title generation prompt not found")); } + + /** + * 레벨별 요약 프롬프트 조회 + */ + public Optional getSummaryPromptByLevel(int level) { + return loadPromptPort.loadSummaryPromptByLevel(level); + } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java index 7bd155a8..e0fcf6c0 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/ProcessMessageUseCase.java @@ -12,6 +12,9 @@ public interface ProcessMessageUseCase { // 제목 생성 처리 CompletableFuture processTitleGeneration(ProcessTitleGenerationCommand command); + + // 4단계 대화 요약 처리 + CompletableFuture processConversationSummary(ProcessConversationSummaryCommand command); @Data @Builder @@ -36,4 +39,11 @@ class ProcessAnswerCommand { class ProcessTitleGenerationCommand { private Long chatRoomId; } + + @Data + @Builder + class ProcessConversationSummaryCommand { + private Long chatRoomId; + private int level; + } } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java index 799506f1..9f358cba 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java @@ -21,6 +21,10 @@ public interface LoadMessagesPort { List loadChatRoomLevelAndDetailedLevelMessages(ChatRoomId chatRoomId, int level, int detailedLevel); + List loadRecentMessagesByLevel(ChatRoomId chatRoomId, int level, int limit); + + long countMessagesByLevel(ChatRoomId chatRoomId, int level); + @Data @AllArgsConstructor class ChatRoomMessageRepositoryDto { diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadSummarizedMessages.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadSummarizedMessages.java index 3ea3bd0f..9a30d233 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadSummarizedMessages.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadSummarizedMessages.java @@ -4,8 +4,11 @@ import makeus.cmc.malmo.domain.value.id.ChatRoomId; import java.util.List; +import java.util.Optional; public interface LoadSummarizedMessages { List loadSummarizedMessages(ChatRoomId chatRoomId); + + Optional loadLatestSummaryByLevel(ChatRoomId chatRoomId, int level); } diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java new file mode 100644 index 00000000..c96669b6 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveChatMessageSummaryPort.java @@ -0,0 +1,7 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; + +public interface SaveChatMessageSummaryPort { + ChatMessageSummary saveChatMessageSummary(ChatMessageSummary chatMessageSummary); +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index bed7cabc..f54b9fb5 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import makeus.cmc.malmo.adaptor.message.RequestConversationSummaryMessage; import makeus.cmc.malmo.application.helper.chat_room.ChatRoomCommandHelper; import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; import makeus.cmc.malmo.application.helper.chat_room.PromptQueryHelper; @@ -13,10 +14,12 @@ import makeus.cmc.malmo.application.port.in.chat.ProcessMessageUseCase; import makeus.cmc.malmo.application.port.in.chat.SufficiencyCheckResult; import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.model.chat.ChatMessageSummary; import makeus.cmc.malmo.domain.model.chat.ChatRoom; import makeus.cmc.malmo.domain.model.chat.Prompt; import makeus.cmc.malmo.domain.model.chat.DetailedPrompt; import makeus.cmc.malmo.domain.model.chat.MemberChatRoomMetadata; +import makeus.cmc.malmo.util.ChatTokenConstants; import makeus.cmc.malmo.domain.model.member.Member; import makeus.cmc.malmo.domain.model.member.MemberMemory; import makeus.cmc.malmo.domain.model.question.CoupleQuestion; @@ -29,11 +32,14 @@ import makeus.cmc.malmo.util.ChatMessageSplitter; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import static makeus.cmc.malmo.adaptor.message.StreamMessageType.REQUEST_CONVERSATION_SUMMARY; + @Slf4j @Service @RequiredArgsConstructor @@ -146,9 +152,22 @@ public CompletableFuture processAnswerMetadata(ProcessAnswerCommand comman * - 충분성 검사 없이 바로 응답 생성 * - 단계 전환 없이 현재 level 유지 * - 메타데이터 저장 스킵 + * - 토큰 관리를 위한 요약 트리거 포함 */ private CompletableFuture processFreeConversation(Member member, ChatRoom chatRoom, ProcessMessageCommand command) { - List> messages = chatPromptBuilder.createForProcessUserMessage( + ChatRoomId chatRoomId = ChatRoomId.of(chatRoom.getId()); + int level = chatRoom.getLevel(); + + // 메시지 개수 체크 및 요약 트리거 + long messageCount = chatRoomQueryHelper.countMessagesByLevel(chatRoomId, level); + if (messageCount > ChatTokenConstants.FREE_CONVERSATION_SUMMARY_THRESHOLD + && messageCount % ChatTokenConstants.FREE_CONVERSATION_SUMMARY_INTERVAL == 0) { + // 비동기로 요약 생성 요청 (20개 단위로) + requestConversationSummaryAsync(chatRoom); + } + + // createForFreeConversation 사용 (최근 20개 + 요약 포함) + List> messages = chatPromptBuilder.createForFreeConversation( member, chatRoom, command.getNowMessage()); Prompt systemPrompt = promptQueryHelper.getSystemPrompt(); @@ -289,6 +308,65 @@ public CompletableFuture processTitleGeneration(ProcessTitleGenerationComm }); } + @Override + public CompletableFuture processConversationSummary(ProcessConversationSummaryCommand command) { + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + int level = command.getLevel(); + + // 최신 요약 이후의 메시지들을 가져와서 요약 + // 최근 요약이 있다면 그 이후의 메시지, 없다면 전체 메시지 중 최근 요약 주기만큼 + List messagesToSummarize = chatRoomQueryHelper.getRecentMessages( + chatRoomId, level, ChatTokenConstants.FREE_CONVERSATION_SUMMARY_INTERVAL); + + if (messagesToSummarize.isEmpty()) { + log.debug("No messages to summarize for chatRoomId: {}, level: {}", chatRoomId.getValue(), level); + return CompletableFuture.completedFuture(null); + } + + // 요약할 메시지들을 프롬프트 형식으로 변환 + List> summaryMessages = new ArrayList<>(); + for (ChatMessage chatMessage : messagesToSummarize) { + summaryMessages.add(Map.of( + "role", chatMessage.getSenderType().getApiName(), + "content", chatMessage.getContent() + )); + } + + // 요약 프롬프트 조회 (4단계 요약 프롬프트 사용) + Prompt summaryPrompt = promptQueryHelper.getSummaryPromptByLevel(level) + .orElseGet(() -> { + log.warn("Summary prompt not found for level: {}, using default", level); + return promptQueryHelper.getSummaryPromptByLevel(3) + .orElseThrow(() -> new RuntimeException("Summary prompt not found")); + }); + + // 비동기 요약 생성 및 저장 + return chatProcessor.requestConversationSummary(summaryMessages, summaryPrompt) + .thenAcceptAsync(summaryContent -> { + ChatMessageSummary chatMessageSummary = ChatMessageSummary.createChatMessageSummary( + chatRoomId, summaryContent, level); + chatRoomCommandHelper.saveChatMessageSummary(chatMessageSummary); + log.info("Conversation summary saved for chatRoomId: {}, level: {}", chatRoomId.getValue(), level); + }) + .exceptionally(throwable -> { + log.error("Failed to generate conversation summary for chatRoomId: {}, level: {}", + chatRoomId.getValue(), level, throwable); + return null; + }); + } + + /** + * 비동기 4단계 대화 요약 생성 요청 + * Redis Stream을 통해 요약 생성 워커에 전달 + */ + private void requestConversationSummaryAsync(ChatRoom chatRoom) { + outboxHelper.publish( + REQUEST_CONVERSATION_SUMMARY, + new RequestConversationSummaryMessage(chatRoom.getId(), chatRoom.getLevel()) + ); + log.info("Conversation summary requested for chatRoomId: {}, level: {}", chatRoom.getId(), chatRoom.getLevel()); + } + private void saveAiMessage(MemberId memberId, ChatRoomId chatRoomId, int level, int detailedLevel, String fullAnswer) { // fullAnswer를 문장 단위로 분할하고 세 문장씩 그룹화 List groupedTexts = ChatMessageSplitter.splitIntoGroups(fullAnswer); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java index 3339c37f..5305c95a 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatProcessor.java @@ -100,6 +100,17 @@ public CompletableFuture requestDetailedSummary(List return requestChatApiPort.requestResponse(messages); } + /** + * 4단계 자유 대화 요약 생성 + * @param messages 요약할 메시지 목록 + * @param summaryPrompt 요약 프롬프트 + * @return 생성된 요약 문자열 + */ + public CompletableFuture requestConversationSummary(List> messages, + Prompt summaryPrompt) { + messages.add(createMessageMap(SenderType.SYSTEM, summaryPrompt.getContent())); + return requestChatApiPort.requestResponse(messages); + } /** * 제목 생성 요청 diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java index 91a08cd2..0d7da67e 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatPromptBuilder.java @@ -7,6 +7,7 @@ import makeus.cmc.malmo.domain.model.chat.ChatMessage; import makeus.cmc.malmo.domain.model.chat.ChatRoom; import makeus.cmc.malmo.domain.model.chat.MemberChatRoomMetadata; +import makeus.cmc.malmo.util.ChatTokenConstants; import makeus.cmc.malmo.domain.model.member.Member; import makeus.cmc.malmo.domain.model.member.MemberMemory; import makeus.cmc.malmo.domain.value.id.ChatRoomId; @@ -196,4 +197,45 @@ public List> createForTitleGeneration(ChatRoom chatRoom) { return messages; } + + /** + * 4단계 자유 대화를 위한 메시지 구성 + * - 최근 20개 메시지만 로드하여 토큰 관리 + * - 이전 대화 요약 포함 + */ + public List> createForFreeConversation(Member member, ChatRoom chatRoom, String userMessage) { + List> messages = new ArrayList<>(); + int chatRoomLevel = chatRoom.getLevel(); + ChatRoomId chatRoomId = ChatRoomId.of(chatRoom.getId()); + + // 1. 사용자 메타데이터 + String metaDataContent = getMetaDataContent(member); + messages.add(createMessageMap(SenderType.USER, metaDataContent)); + + // 2. 이전 단계 요약 (MemberChatRoomMetadata) + List metadataList = memberChatRoomMetadataQueryHelper.getMemberChatRoomMetadata(chatRoomId); + if (!metadataList.isEmpty()) { + String metadataContent = getMemberChatRoomMetadataContent(metadataList); + messages.add(createMessageMap(SenderType.SYSTEM, metadataContent)); + } + + // 3. 4단계 대화 요약 (ChatMessageSummary) - 있는 경우 + chatRoomQueryHelper.getLatestSummaryByLevel(chatRoomId, chatRoomLevel) + .ifPresent(summary -> { + String summaryContent = "[이전 대화 요약]\n" + summary.getContent(); + messages.add(createMessageMap(SenderType.SYSTEM, summaryContent)); + }); + + // 4. 최근 N개 메시지만 로드 (토큰 관리) + List recentMessages = chatRoomQueryHelper.getRecentMessages( + chatRoomId, chatRoomLevel, ChatTokenConstants.FREE_CONVERSATION_RECENT_MESSAGE_LIMIT); + for (ChatMessage chatMessage : recentMessages) { + messages.add(createMessageMap(chatMessage.getSenderType(), chatMessage.getContent())); + } + + // 5. 현재 사용자 메시지 추가 + messages.add(createMessageMap(SenderType.USER, userMessage)); + + return messages; + } } diff --git a/src/main/java/makeus/cmc/malmo/util/ChatTokenConstants.java b/src/main/java/makeus/cmc/malmo/util/ChatTokenConstants.java new file mode 100644 index 00000000..eaf5dd09 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/util/ChatTokenConstants.java @@ -0,0 +1,18 @@ +package makeus.cmc.malmo.util; + +public class ChatTokenConstants { + /** + * 4단계 자유 대화에서 최근 메시지 개수 제한 + */ + public static final int FREE_CONVERSATION_RECENT_MESSAGE_LIMIT = 20; + + /** + * 4단계 자유 대화에서 요약 생성 임계값 (이 개수 초과 시 요약 생성) + */ + public static final int FREE_CONVERSATION_SUMMARY_THRESHOLD = 30; + + /** + * 4단계 자유 대화에서 요약 생성 주기 (이 개수 단위로 요약 생성) + */ + public static final int FREE_CONVERSATION_SUMMARY_INTERVAL = 20; +} From 78d9e94aa762847e59fcabf2e3f0b86d8766f76a Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:00:40 +0900 Subject: [PATCH 10/14] docs: update sqls --- sqls/MM-169.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqls/MM-169.sql b/sqls/MM-169.sql index a6e9a583..2c09fc39 100644 --- a/sqls/MM-169.sql +++ b/sqls/MM-169.sql @@ -1,6 +1,6 @@ CREATE TABLE bookmark_entity ( - bookmark_id BIGINT NOT NULL, + bookmark_id BIGINT NOT NULL AUTO_INCREMENT, created_at datetime NULL, modified_at datetime NULL, deleted_at datetime NULL, From 9c00f002e2ef6811c1071db6b8659f90e14eae6d Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:06:06 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=A0=84=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../adapter/ChatRoomPersistenceAdapter.java | 6 ++ .../repository/chat/ChatRoomRepository.java | 5 ++ .../helper/chat_room/ChatRoomQueryHelper.java | 5 ++ .../port/out/chat/LoadChatRoomPort.java | 3 + .../chat/ChatRoomManagementService.java | 19 +++- .../application/service/chat/ChatService.java | 6 ++ .../cmc/malmo/domain/model/chat/ChatRoom.java | 15 +++- .../domain/value/state/ChatRoomState.java | 3 +- .../ChatRoomIntegrationTest.java | 86 +++++++++++++++++-- 10 files changed, 135 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 0ca8831f..20b29a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ out/ /src/main/resources/data-ver1.sql /src/main/resources/data-ver2.sql CLAUDE.md +.sisyphus \ No newline at end of file diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 061833f0..8dea6e82 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -91,6 +91,12 @@ public List loadActiveChatRoomsByMemberId(MemberId memberId) { .toList(); } + @Override + public Optional loadBeforeInitChatRoomByMemberId(MemberId memberId) { + return chatRoomRepository.findBeforeInitChatRoomByMemberEntityId(memberId.getValue()) + .map(chatRoomMapper::toDomain); + } + @Override public ChatRoom saveChatRoom(ChatRoom chatRoom) { ChatRoomEntity entity = chatRoomMapper.toEntity(chatRoom); diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java index 9a063af1..70eea7ef 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepository.java @@ -5,10 +5,15 @@ import org.springframework.data.jpa.repository.Query; import java.util.List; +import java.util.Optional; public interface ChatRoomRepository extends JpaRepository, ChatRoomRepositoryCustom { // 진행 중인 채팅방 목록 조회 (ALIVE 상태만) @Query("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = ?1 AND c.chatRoomState = 'ALIVE' ORDER BY c.lastMessageSentTime DESC") List findActiveChatRoomsByMemberEntityId(Long memberId); + + // 초기화 전 채팅방 조회 (BEFORE_INIT 상태, 1개만) + @Query("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = ?1 AND c.chatRoomState = 'BEFORE_INIT' ORDER BY c.createdAt DESC LIMIT 1") + Optional findBeforeInitChatRoomByMemberEntityId(Long memberId); } diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java index 8823cf5a..9c1382bf 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java @@ -39,6 +39,11 @@ public List getActiveChatRoomsByMemberId(MemberId memberId) { return loadChatRoomPort.loadActiveChatRoomsByMemberId(memberId); } + // 초기화 전 채팅방 조회 (BEFORE_INIT 상태) + public Optional getBeforeInitChatRoomByMemberId(MemberId memberId) { + return loadChatRoomPort.loadBeforeInitChatRoomByMemberId(memberId); + } + public LoadChatRoomMetadataPort.ChatRoomMetadataDto getChatRoomMetadata(MemberId memberId) { return loadChatRoomMetadataPort.loadChatRoomMetadata(memberId) .orElse(new LoadChatRoomMetadataPort.ChatRoomMetadataDto(null, null)); diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java index 31350f6a..c0c5273d 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadChatRoomPort.java @@ -13,6 +13,9 @@ public interface LoadChatRoomPort { // 진행 중인 채팅방 목록 조회 (복수) List loadActiveChatRoomsByMemberId(MemberId memberId); + // 초기화 전 채팅방 조회 (BEFORE_INIT 상태) + Optional loadBeforeInitChatRoomByMemberId(MemberId memberId); + // ID로 채팅방 조회 (유지) Optional loadChatRoomById(ChatRoomId chatRoomId); diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java index c7015930..c388aa6d 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomManagementService.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import makeus.cmc.malmo.adaptor.in.aop.CheckValidMember; import makeus.cmc.malmo.application.helper.chat_room.ChatRoomCommandHelper; +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; import makeus.cmc.malmo.application.helper.member.MemberQueryHelper; import makeus.cmc.malmo.application.port.in.chat.CreateChatRoomUseCase; import makeus.cmc.malmo.domain.model.chat.ChatMessage; @@ -16,6 +17,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHATROOM_LEVEL; import static makeus.cmc.malmo.util.GlobalConstants.INIT_CHAT_MESSAGE; @@ -26,6 +29,7 @@ public class ChatRoomManagementService implements CreateChatRoomUseCase { private final ChatRoomDomainService chatRoomDomainService; private final MemberQueryHelper memberQueryHelper; + private final ChatRoomQueryHelper chatRoomQueryHelper; private final ChatRoomCommandHelper chatRoomCommandHelper; @Override @@ -35,11 +39,20 @@ public CreateChatRoomResponse createChatRoom(CreateChatRoomCommand command) { MemberId memberId = MemberId.of(command.getUserId()); Member member = memberQueryHelper.getMemberByIdOrThrow(memberId); - // 채팅방 생성 (즉시 ALIVE 상태) + Optional existingBeforeInitRoom = chatRoomQueryHelper.getBeforeInitChatRoomByMemberId(memberId); + if (existingBeforeInitRoom.isPresent()) { + ChatRoom existingRoom = existingBeforeInitRoom.get(); + log.info("기존 BEFORE_INIT 채팅방 반환: chatRoomId={}, memberId={}", existingRoom.getId(), memberId.getValue()); + return CreateChatRoomResponse.builder() + .chatRoomId(existingRoom.getId()) + .chatRoomState(existingRoom.getChatRoomState()) + .createdAt(existingRoom.getCreatedAt()) + .build(); + } + ChatRoom chatRoom = chatRoomDomainService.createChatRoom(memberId); ChatRoom savedChatRoom = chatRoomCommandHelper.saveChatRoom(chatRoom); - // 초기 AI 메시지 생성 및 저장 ChatMessage initMessage = chatRoomDomainService.createAiMessage( ChatRoomId.of(savedChatRoom.getId()), INIT_CHATROOM_LEVEL, @@ -47,7 +60,7 @@ public CreateChatRoomResponse createChatRoom(CreateChatRoomCommand command) { JosaUtils.아야(member.getNickname()) + INIT_CHAT_MESSAGE); chatRoomCommandHelper.saveChatMessage(initMessage); - log.info("새 채팅방 생성: chatRoomId={}, memberId={}", savedChatRoom.getId(), memberId.getValue()); + log.info("새 BEFORE_INIT 채팅방 생성: chatRoomId={}, memberId={}", savedChatRoom.getId(), memberId.getValue()); return CreateChatRoomResponse.builder() .chatRoomId(savedChatRoom.getId()) diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java index 02bb8b7a..fd60740a 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatService.java @@ -53,6 +53,12 @@ public SendChatMessageResponse processUserMessage(SendChatMessageCommand command Member member = memberQueryHelper.getMemberByIdOrThrow(memberId); ChatRoom chatRoom = chatRoomQueryHelper.getChatRoomByIdOrThrow(chatRoomId); + if (chatRoom.isBeforeInit()) { + chatRoom.initialize(); + chatRoomCommandHelper.saveChatRoom(chatRoom); + log.info("채팅방 상태 전환: BEFORE_INIT -> ALIVE, chatRoomId={}", chatRoomId.getValue()); + } + // 현재 유저 메시지를 저장 ChatMessage savedUserMessage = saveUserMessage(chatRoom, command.getMessage()); diff --git a/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java b/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java index 3873e97b..87b94ff7 100644 --- a/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java +++ b/src/main/java/makeus/cmc/malmo/domain/model/chat/ChatRoom.java @@ -40,7 +40,7 @@ public static ChatRoom createChatRoom(MemberId memberId) { .memberId(memberId) .level(INIT_CHATROOM_LEVEL) .detailedLevel(1) - .chatRoomState(ChatRoomState.ALIVE) + .chatRoomState(ChatRoomState.BEFORE_INIT) .lastMessageSentTime(LocalDateTime.now()) .title(null) // 제목은 1단계 종료 후 생성 // 새 채팅방은 보고서 관련 필드 null @@ -96,7 +96,18 @@ public void updateTitle(String title) { } public boolean isChatRoomValid() { - return this.chatRoomState == ChatRoomState.ALIVE; + return this.chatRoomState == ChatRoomState.ALIVE + || this.chatRoomState == ChatRoomState.BEFORE_INIT; + } + + public boolean isBeforeInit() { + return this.chatRoomState == ChatRoomState.BEFORE_INIT; + } + + public void initialize() { + if (this.chatRoomState == ChatRoomState.BEFORE_INIT) { + this.chatRoomState = ChatRoomState.ALIVE; + } } public boolean isOwner(MemberId memberId) { diff --git a/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java b/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java index 783729a5..c494d223 100644 --- a/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java +++ b/src/main/java/makeus/cmc/malmo/domain/value/state/ChatRoomState.java @@ -1,7 +1,8 @@ package makeus.cmc.malmo.domain.value.state; public enum ChatRoomState { - ALIVE, // 진행 중 (생성 즉시 ALIVE) + BEFORE_INIT, // 초기화 전 (사용자 첫 메시지 전) + ALIVE, // 진행 중 (사용자 첫 메시지 후) COMPLETED, // 기존 완료된 채팅방 (보고서 조회용) DELETED // 삭제됨 (soft delete) } \ No newline at end of file diff --git a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java index fbe9763c..d656f58c 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java @@ -123,13 +123,13 @@ class CreateChatRoom { .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.chatRoomId").exists()) - .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.ALIVE.name())); + .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.BEFORE_INIT.name())); List chatRooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId", ChatRoomEntity.class) .setParameter("memberId", member.getId()) .getResultList(); Assertions.assertThat(chatRooms).hasSize(1); - Assertions.assertThat(chatRooms.get(0).getChatRoomState()).isEqualTo(ChatRoomState.ALIVE); + Assertions.assertThat(chatRooms.get(0).getChatRoomState()).isEqualTo(ChatRoomState.BEFORE_INIT); List messages = em.createQuery("SELECT m FROM ChatMessageEntity m WHERE m.chatRoomEntityId.value = :chatRoomId", ChatMessageEntity.class) .setParameter("chatRoomId", chatRooms.get(0).getId()) @@ -149,24 +149,94 @@ class CreateChatRoom { } @Test - @DisplayName("여러 개의 채팅방을 생성할 수 있다") - void 다중_채팅방_생성_성공() throws Exception { + @DisplayName("BEFORE_INIT 채팅방이 있으면 같은 채팅방을 반환한다") + void BEFORE_INIT_채팅방_재사용() throws Exception { // when - 첫 번째 채팅방 생성 mockMvc.perform(post("/chatrooms") .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()); - // when - 두 번째 채팅방 생성 + // when - 두 번째 채팅방 생성 시도 (기존 BEFORE_INIT 반환) mockMvc.perform(post("/chatrooms") .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()); - // then + // then - 같은 BEFORE_INIT 채팅방이 반환되어 1개만 존재 List chatRooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId AND c.chatRoomState = :state", ChatRoomEntity.class) .setParameter("memberId", member.getId()) - .setParameter("state", ChatRoomState.ALIVE) + .setParameter("state", ChatRoomState.BEFORE_INIT) .getResultList(); - Assertions.assertThat(chatRooms).hasSize(2); + Assertions.assertThat(chatRooms).hasSize(1); + } + + @Test + @DisplayName("첫 메시지 전송 시 BEFORE_INIT에서 ALIVE로 상태 전환된다") + void 첫_메시지로_상태_전환() throws Exception { + // given - BEFORE_INIT 채팅방 생성 + mockMvc.perform(post("/chatrooms") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + List beforeInitRooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId", ChatRoomEntity.class) + .setParameter("memberId", member.getId()) + .getResultList(); + Assertions.assertThat(beforeInitRooms).hasSize(1); + Long chatRoomId = beforeInitRooms.get(0).getId(); + Assertions.assertThat(beforeInitRooms.get(0).getChatRoomState()).isEqualTo(ChatRoomState.BEFORE_INIT); + + em.flush(); + em.clear(); + + // when - 첫 메시지 전송 + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", chatRoomId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto("안녕하세요")))) + .andExpect(status().isOk()); + + // then - ALIVE로 상태 전환 확인 + ChatRoomEntity updatedRoom = em.find(ChatRoomEntity.class, chatRoomId); + Assertions.assertThat(updatedRoom.getChatRoomState()).isEqualTo(ChatRoomState.ALIVE); + } + + @Test + @DisplayName("ALIVE 상태가 되면 새 채팅방 생성이 가능하다") + void ALIVE_후_새_채팅방_생성() throws Exception { + // given - BEFORE_INIT 채팅방 생성 및 ALIVE 전환 + mockMvc.perform(post("/chatrooms") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + List rooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId", ChatRoomEntity.class) + .setParameter("memberId", member.getId()) + .getResultList(); + Long firstRoomId = rooms.get(0).getId(); + + em.flush(); + em.clear(); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/messages", firstRoomId) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createSendChatMessageRequestDto("첫 메시지")))) + .andExpect(status().isOk()); + + em.flush(); + em.clear(); + + // when - 새 채팅방 생성 시도 + mockMvc.perform(post("/chatrooms") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.chatRoomState").value(ChatRoomState.BEFORE_INIT.name())); + + // then - 2개의 채팅방 존재 (ALIVE 1개, BEFORE_INIT 1개) + List allRooms = em.createQuery("SELECT c FROM ChatRoomEntity c WHERE c.memberEntityId.value = :memberId ORDER BY c.createdAt", ChatRoomEntity.class) + .setParameter("memberId", member.getId()) + .getResultList(); + Assertions.assertThat(allRooms).hasSize(2); + Assertions.assertThat(allRooms.get(0).getChatRoomState()).isEqualTo(ChatRoomState.ALIVE); + Assertions.assertThat(allRooms.get(1).getChatRoomState()).isEqualTo(ChatRoomState.BEFORE_INIT); } } From 5ab24a4fc53d5c38370d36a77ac595c5e80f1a34 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:12:00 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=88=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=ED=99=9C=EC=84=B1=ED=99=94=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=A7=8C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/chat/ChatRoomRepositoryCustomImpl.java | 2 +- .../cmc/malmo/integration_test/MemberIntegrationTest.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java index 2febcb9e..bad489ec 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java @@ -70,7 +70,7 @@ public int countChatRoomsByMemberId(Long memberId) { .select(chatRoomEntity.count().intValue()) .from(chatRoomEntity) .where(chatRoomEntity.memberEntityId.value.eq(memberId) - .and(chatRoomEntity.chatRoomState.eq(ChatRoomState.COMPLETED))) + .and(chatRoomEntity.chatRoomState.eq(ChatRoomState.ALIVE))) .fetchOne(); } } diff --git a/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java index cf1c53ba..8fa45967 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/MemberIntegrationTest.java @@ -1546,12 +1546,9 @@ private CoupleQuestionEntity createAndSaveCoupleQuestion(QuestionEntity question private ChatRoomEntity createAndSaveChatRoom() { ChatRoomEntity chatRoom = ChatRoomEntity.builder() .memberEntityId(MemberEntityId.of(member.getId())) - .chatRoomState(ChatRoomState.COMPLETED) + .chatRoomState(ChatRoomState.ALIVE) .level(1) .lastMessageSentTime(LocalDateTime.now()) - .totalSummary("테스트 요약") - .situationKeyword("테스트 상황 키워드") - .solutionKeyword("테스트 해결 키워드") .build(); em.persist(chatRoom); From 69286783d34924af391e69fc65b2f89a5961bc87 Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:26:13 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=EC=9D=B4=20=EC=88=98=EC=A0=95=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../malmo/application/service/chat/ChatMessageService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java index f54b9fb5..b9c69b21 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatMessageService.java @@ -294,6 +294,11 @@ private void requestTitleGenerationAsync(ChatRoom chatRoom) { public CompletableFuture processTitleGeneration(ProcessTitleGenerationCommand command) { ChatRoom chatRoom = chatRoomQueryHelper.getChatRoomByIdOrThrow(ChatRoomId.of(command.getChatRoomId())); + if (chatRoom.getTitle() != null) { + log.debug("Title already exists for chatRoomId: {}, skipping generation", command.getChatRoomId()); + return CompletableFuture.completedFuture(null); + } + // 1단계 대화 내용 조회 List> messages = chatPromptBuilder.createForTitleGeneration(chatRoom); From 0e8e36875cb54175acb16d64458685de7700d52b Mon Sep 17 00:00:00 2001 From: Choi wontak <76509639+RoundTable02@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:10:33 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20ALIVE=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=9D=98=20=EC=B1=84=ED=8C=85=EB=B0=A9=EC=9D=84=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/ChatRoomRepositoryCustomImpl.java | 2 +- .../ChatRoomIntegrationTest.java | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java index bad489ec..a0c5c00c 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatRoomRepositoryCustomImpl.java @@ -48,7 +48,7 @@ public boolean isMemberOwnerOfChatRooms(Long memberId, List chatRoomIds) { Long count = queryFactory.select(chatRoomEntity.count()) .from(chatRoomEntity) .where(chatRoomEntity.memberEntityId.value.eq(memberId) - .and(chatRoomEntity.chatRoomState.eq(ChatRoomState.COMPLETED)) + .and(chatRoomEntity.chatRoomState.ne(ChatRoomState.DELETED)) .and(chatRoomEntity.id.in(chatRoomIds))) .fetchOne(); diff --git a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java index d656f58c..f13ac238 100644 --- a/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java +++ b/src/test/java/makeus/cmc/malmo/integration_test/ChatRoomIntegrationTest.java @@ -529,6 +529,57 @@ class DeleteChatRoom { Assertions.assertThat(em.find(ChatRoomEntity.class, chatRoom2.getId()).getChatRoomState()).isEqualTo(ChatRoomState.DELETED); } + @Test + @DisplayName("ALIVE 상태 채팅방 삭제에 성공한다") + void ALIVE_상태_채팅방_삭제_성공() throws Exception { + // given + ChatRoomEntity chatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .build(); + em.persist(chatRoom); + em.flush(); + em.clear(); + + // when & then + mockMvc.perform(delete("/chatrooms") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createDeleteChatRoomsRequestDto(List.of(chatRoom.getId()))))) + .andExpect(status().isOk()); + + ChatRoomEntity deletedChatRoom = em.find(ChatRoomEntity.class, chatRoom.getId()); + Assertions.assertThat(deletedChatRoom.getChatRoomState()).isEqualTo(ChatRoomState.DELETED); + } + + @Test + @DisplayName("ALIVE와 COMPLETED 상태 채팅방 혼합 삭제에 성공한다") + void ALIVE_COMPLETED_혼합_삭제_성공() throws Exception { + // given + ChatRoomEntity aliveChatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.ALIVE) + .build(); + ChatRoomEntity completedChatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .build(); + em.persist(aliveChatRoom); + em.persist(completedChatRoom); + em.flush(); + em.clear(); + + // when & then + mockMvc.perform(delete("/chatrooms") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(ChatRoomRequestDtoFactory.createDeleteChatRoomsRequestDto(List.of(aliveChatRoom.getId(), completedChatRoom.getId()))))) + .andExpect(status().isOk()); + + Assertions.assertThat(em.find(ChatRoomEntity.class, aliveChatRoom.getId()).getChatRoomState()).isEqualTo(ChatRoomState.DELETED); + Assertions.assertThat(em.find(ChatRoomEntity.class, completedChatRoom.getId()).getChatRoomState()).isEqualTo(ChatRoomState.DELETED); + } + @Test @DisplayName("접근 권한이 없으면 채팅방 삭제에 실패한다") void 접근_권한_없으면_삭제_실패() throws Exception {