diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java new file mode 100644 index 00000000..07b6da16 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.chat.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.SchemaProperty; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "[채팅 API]") +public interface ChatApi { + @Operation(summary = "채팅 조회", description = "채팅방의 채팅 이력을 무한스크롤 조회합니다. 결과는 최신순으로 정렬되며, `lastMessageId`를 포함하지 않은 이전 메시지들을 조회합니다.
content는 채팅방 조회의 `recentMessages` 필드와 동일합니다.") + @Parameters({ + @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH), + @Parameter(name = "lastMessageId", description = "마지막으로 읽은 메시지 ID", required = true, in = ParameterIn.QUERY), + @Parameter(name = "size", description = "조회할 채팅 수(default: 30)", example = "30", required = false, in = ParameterIn.QUERY) + }) + @ApiResponse(responseCode = "200", description = "채팅 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chats", schema = @Schema(implementation = SliceResponseTemplate.class)))) + ResponseEntity readChats( + @PathVariable("chatRoomId") Long chatRoomId, + @RequestParam(value = "lastMessageId") Long lastMessageId, + @RequestParam(value = "size", defaultValue = "30") int size + ); +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java new file mode 100644 index 00000000..2b773d3d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.api.apis.chat.controller; + +import kr.co.pennyway.api.apis.chat.api.ChatApi; +import kr.co.pennyway.api.apis.chat.usecase.ChatUseCase; +import kr.co.pennyway.api.common.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/chat-rooms/{chatRoomId}/chats") +public class ChatController implements ChatApi { + private static final String CHATS = "chats"; + + private final ChatUseCase chatUseCase; + + @Override + @GetMapping("") + @PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)") + public ResponseEntity readChats( + @PathVariable("chatRoomId") Long chatRoomId, + @RequestParam(value = "lastMessageId") Long lastMessageId, + @RequestParam(value = "size", defaultValue = "30") int size + ) { + return ResponseEntity.ok(SuccessResponse.from(CHATS, chatUseCase.readChats(chatRoomId, lastMessageId, size))); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java new file mode 100644 index 00000000..b3b5ee90 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.api.apis.chat.mapper; + +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.common.annotation.Mapper; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import org.springframework.data.domain.Slice; + +import java.util.List; + +@Mapper +public class ChatMapper { + public static SliceResponseTemplate toChatDetails(Slice chatMessages) { + List details = chatMessages.getContent().stream() + .map(ChatRes.ChatDetail::from) + .toList(); + + return SliceResponseTemplate.of(details, chatMessages.getPageable(), chatMessages.getNumberOfElements(), chatMessages.hasNext()); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java new file mode 100644 index 00000000..215b63b7 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.api.apis.chat.service; + +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatSearchService { + private final ChatMessageService chatMessageService; + + public Slice readChats(Long chatRoomId, Long lastMessageId, int size) { + return chatMessageService.readMessagesBefore(chatRoomId, lastMessageId, size); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java new file mode 100644 index 00000000..70baf630 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.api.apis.chat.usecase; + +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.apis.chat.mapper.ChatMapper; +import kr.co.pennyway.api.apis.chat.service.ChatSearchService; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.common.annotation.UseCase; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Slice; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class ChatUseCase { + private final ChatSearchService chatSearchService; + + public SliceResponseTemplate readChats(Long chatRoomId, Long lastMessageId, int size) { + Slice chatMessages = chatSearchService.readChats(chatRoomId, lastMessageId, size); + + return ChatMapper.toChatDetails(chatMessages); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java new file mode 100644 index 00000000..0c9b50b0 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java @@ -0,0 +1,257 @@ +package kr.co.pennyway.api.apis.chat.integration; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.chat.dto.ChatRes; +import kr.co.pennyway.api.common.response.SliceResponseTemplate; +import kr.co.pennyway.api.common.response.SuccessResponse; +import kr.co.pennyway.api.common.util.ApiTestHelper; +import kr.co.pennyway.api.common.util.RequestParameters; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.ChatMemberFixture; +import kr.co.pennyway.api.config.fixture.ChatRoomFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; +import kr.co.pennyway.domain.common.redis.message.domain.ChatMessageBuilder; +import kr.co.pennyway.domain.common.redis.message.repository.ChatMessageRepository; +import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType; +import kr.co.pennyway.domain.common.redis.message.type.MessageContentType; +import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom; +import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository; +import kr.co.pennyway.domain.domains.member.domain.ChatMember; +import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository; +import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.infra.client.guid.IdGenerator; +import kr.co.pennyway.infra.common.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@ExternalApiIntegrationTest +public class ChatPaginationGetIntegrationTest extends ExternalApiDBTestConfig { + private static final String BASE_URL = "/v2/chat-rooms/{chatRoomId}/chats"; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ChatRoomRepository chatRoomRepository; + + @Autowired + private ChatMemberRepository chatMemberRepository; + + @Autowired + private ChatMessageRepository chatMessageRepository; + + @Autowired + private JwtProvider accessTokenProvider; + + @Autowired + private IdGenerator idGenerator; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private RedisTemplate redisTemplate; + + private ApiTestHelper apiTestHelper; + + @BeforeEach + void setUp() { + apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider); + } + + @AfterEach + void tearDown() { + Set keys = redisTemplate.keys("chatroom:*:message"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + @Test + @DisplayName("채팅방의 이전 메시지들을 정상적으로 페이징하여 조회할 수 있다") + void successReadChats() { + // given + User user = createUser(); + ChatRoom chatRoom = createChatRoom(); + createChatMember(user, chatRoom, ChatMemberRole.ADMIN); + List messages = setupTestMessages(chatRoom.getId(), user.getId(), 50); + + // when + ResponseEntity response = performRequest(user, chatRoom.getId(), messages.get(49).getChatId(), 30); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + SliceResponseTemplate slice = extractChatDetail(response); + + assertEquals(messages.get(48).getChatId(), slice.contents().get(0).chatId(), "lastMessageId에 해당하는 메시지는 포함되지 않아야 합니다"); + assertThat(slice.contents()).hasSize(30); + assertThat(slice.hasNext()).isTrue(); + } + ); + } + + @Test + @DisplayName("채팅방 멤버가 아닌 사용자는 메시지를 조회할 수 없다") + void readChatsWithoutPermissionTest() { + // given + User nonMember = createUser(); + ChatRoom chatRoom = createChatRoom(); + + // when + ResponseEntity response = performRequest(nonMember, chatRoom.getId(), 0L, 30); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("존재하지 않는 채팅방의 메시지는 조회할 수 없다") + void readChatsFromNonExistentRoomTest() { + // given + User user = createUser(); + Long nonExistentRoomId = 9999L; + + // when + ResponseEntity response = performRequest(user, nonExistentRoomId, 0L, 30); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("마지막 페이지를 조회할 때 hasNext는 false여야 한다") + void readLastPageTest() { + // given + User user = createUser(); + ChatRoom chatRoom = createChatRoom(); + createChatMember(user, chatRoom, ChatMemberRole.ADMIN); + List messages = setupTestMessages(chatRoom.getId(), user.getId(), 10); + + // when + ResponseEntity response = performRequest(user, chatRoom.getId(), messages.get(0).getChatId(), 10); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + SliceResponseTemplate slice = extractChatDetail(response); + + assertThat(slice.contents()).hasSize(0); + assertThat(slice.hasNext()).isFalse(); + } + ); + } + + @Test + @DisplayName("메시지가 없는 채팅방을 조회하면 빈 리스트를 반환해야 한다") + void readEmptyChatsTest() { + // given + User user = createUser(); + ChatRoom chatRoom = createChatRoom(); + createChatMember(user, chatRoom, ChatMemberRole.ADMIN); + + // when + ResponseEntity response = performRequest(user, chatRoom.getId(), Long.MAX_VALUE, 30); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> { + SliceResponseTemplate slice = extractChatDetail(response); + + assertThat(slice.contents()).isEmpty(); + assertThat(slice.hasNext()).isFalse(); + } + ); + } + + private ResponseEntity performRequest(User user, Long chatRoomId, Long lastMessageId, int size) { + RequestParameters parameters = RequestParameters.defaultGet(BASE_URL) + .user(user) + .queryParams(RequestParameters.createQueryParams("lastMessageId", lastMessageId, "size", size)) + .uriVariables(new Object[]{chatRoomId}) + .build(); + + return apiTestHelper.callApi( + parameters, + new TypeReference>>>() { + } + ); + } + + private User createUser() { + return userRepository.save(UserFixture.GENERAL_USER.toUser()); + } + + private ChatRoom createChatRoom() { + return chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate())); + } + + private ChatMember createChatMember(User user, ChatRoom chatRoom, ChatMemberRole role) { + return switch (role) { + case ADMIN -> chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(user, chatRoom)); + case MEMBER -> chatMemberRepository.save(ChatMemberFixture.MEMBER.toEntity(user, chatRoom)); + }; + } + + private List setupTestMessages(Long chatRoomId, Long senderId, int count) { + List messages = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(chatRoomId) + .chatId(idGenerator.generate()) + .content("Test message " + i) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(senderId) + .build(); + + messages.add(chatMessageRepository.save(message)); + } + + return messages; + } + + private SliceResponseTemplate extractChatDetail(ResponseEntity response) { + SliceResponseTemplate slice = null; + + try { + SuccessResponse>> successResponse = (SuccessResponse>>) response.getBody(); + slice = successResponse.getData().get("chats"); + } catch (Exception e) { + fail("응답 데이터 추출에 실패했습니다.", e); + } + + return slice; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java index c999eb38..fc40818c 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatRoomDetailIntegrationTest.java @@ -22,6 +22,7 @@ import kr.co.pennyway.domain.domains.member.type.ChatMemberRole; import kr.co.pennyway.domain.domains.user.domain.User; import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.client.guid.IdGenerator; import kr.co.pennyway.infra.common.jwt.JwtProvider; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; @@ -73,6 +74,9 @@ public class ChatRoomDetailIntegrationTest extends ExternalApiDBTestConfig { @Autowired private ChatMessageRepositoryImpl chatMessageRepository; + @Autowired + private IdGenerator idGenerator; + private ApiTestHelper apiTestHelper; @LocalServerPort @@ -191,11 +195,11 @@ void successGetChatRoomDetailWithManyParticipants() { ); } - private ChatMessage createTestMessage(Long chatRoomId, Long chatId, Long senderId) { + private ChatMessage createTestMessage(Long chatRoomId, Long idx, Long senderId) { return ChatMessageBuilder.builder() .chatRoomId(chatRoomId) - .chatId(chatId) - .content("Test message " + chatId) + .chatId(idGenerator.generate()) + .content("Test message " + idx) .contentType(MessageContentType.TEXT) .categoryType(MessageCategoryType.NORMAL) .sender(senderId) diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java index b7c76129..9e18637b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/ApiTestHelper.java @@ -9,6 +9,9 @@ import kr.co.pennyway.infra.common.jwt.JwtProvider; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; public final class ApiTestHelper { private final TestRestTemplate restTemplate; @@ -58,6 +61,77 @@ public ResponseEntity callApi( : createErrorResponse(response, body); } + /** + * API 요청을 보내고 응답을 처리하는 일반화된 메서드 + * 쿼리 파라미터를 추가할 수 있습니다. + * + * @param url API 엔드포인트 URL + * @param method HTTP 메서드 + * @param user 요청하는 사용자 + * @param request 요청 바디 (없을 경우 null) + * @param successResponseType 성공 응답 타입 + * @param queryParams 쿼리 파라미터들 + * @param uriVariables URL 변수들 + * @return ResponseEntity + */ + public ResponseEntity callApi( + String url, + HttpMethod method, + User user, + T request, + TypeReference> successResponseType, + Map queryParams, + Object... uriVariables) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url); + if (queryParams != null) { + queryParams.forEach(builder::queryParam); + } + + ResponseEntity response = restTemplate.exchange( + builder.buildAndExpand(uriVariables).toUri(), + method, + createHttpEntity(user, request), + Object.class + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + return response.getStatusCode().is2xxSuccessful() + ? createSuccessResponse(response, body, successResponseType) + : createErrorResponse(response, body); + } + + /** + * API 요청을 보내고 응답을 처리하는 일반화된 메서드 + * + * @param parameters {@link RequestParameters} + * @param successResponseType 성공 응답 타입 + * @return ResponseEntity + */ + public ResponseEntity callApi( + RequestParameters parameters, + TypeReference> successResponseType) { + + ResponseEntity response = restTemplate.exchange( + parameters.createUri(), + parameters.getMethod(), + createHttpEntity(parameters.getUser(), parameters.getRequest()), + Object.class + ); + + Object body = response.getBody(); + if (body == null) { + throw new IllegalStateException("예상치 못한 반환 타입입니다. : " + response); + } + + return response.getStatusCode().is2xxSuccessful() + ? createSuccessResponse(response, body, successResponseType) + : createErrorResponse(response, body); + } + /** * HTTP 요청 엔티티 생성 */ diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java new file mode 100644 index 00000000..589ab594 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/common/util/RequestParameters.java @@ -0,0 +1,118 @@ +package kr.co.pennyway.api.common.util; + +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpMethod; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +@Getter +@Builder +public class RequestParameters { + private final String url; + private final HttpMethod method; + private final User user; + private final Object request; + private final Map queryParams; + private final Object[] uriVariables; + + private RequestParameters(String url, HttpMethod method, User user, + Object request, Map queryParams, Object[] uriVariables) { + validateRequired(url, "URL must not be null"); + validateRequired(method, "HTTP method must not be null"); + validateRequired(user, "User must not be null"); + + this.url = url; + this.method = method; + this.user = user; + this.request = request; + this.queryParams = queryParams; + this.uriVariables = uriVariables; + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * GET 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultGet(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.GET); + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * POST 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultPost(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.POST); + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * PUT 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultPut(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.PUT); + } + + /** + * RequestParameters 생성을 위한 편의 메서드 + * DELETE 메서드를 사용하는 경우에 사용합니다. + */ + public static RequestParametersBuilder defaultDelete(String url) { + return RequestParameters.builder() + .url(url) + .method(HttpMethod.DELETE); + } + + /** + * 쿼리 파라미터 추가를 위한 편의 메서드 + * key-value 쌍으로 쿼리 파라미터를 생성합니다. + * + * @throws IllegalArgumentException key-value 쌍이 제대로 제공되지 않은 경우 + */ + public static Map createQueryParams(Object... keyValues) { + if (keyValues.length % 2 != 0) { + throw new IllegalArgumentException("Key-value pairs must be provided"); + } + + Map params = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + params.put(String.valueOf(keyValues[i]), String.valueOf(keyValues[i + 1])); + } + return params; + } + + private void validateRequired(Object value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + } + + /** + * URI를 생성합니다. + * 쿼리 파라미터와 URI 변수를 적용합니다. + */ + public URI createUri() { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url); + + // 쿼리 파라미터 적용 + if (queryParams != null && !queryParams.isEmpty()) { + queryParams.forEach(builder::queryParam); + } + + // URI 변수 적용 + return (uriVariables != null && uriVariables.length > 0) + ? builder.buildAndExpand(uriVariables).toUri() + : builder.build().toUri(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java index ab60c1b4..5f7801ab 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepository.java @@ -42,8 +42,9 @@ public interface ChatMessageRepository { * 사용자가 마지막으로 읽은 메시지 이후의 안 읽은 메시지 개수를 조회합니다. * * @param roomId 채팅방 ID - * @param lastReadMessageId 사용자가 마지막으로 읽은 메시지의 TSID + * @param lastReadMessageId 사용자가 마지막으로 읽은 메시지의 TSID. 이 값이 0일 경우 모든 메시지 개수를 조회합니다. * @return 안 읽은 메시지 개수 + * @throws IllegalArgumentException lastReadMessageId가 null이거나 음수인 경우 */ Long countUnreadMessages(Long roomId, Long lastReadMessageId); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImpl.java index dcf5bb11..0268654a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImpl.java @@ -6,7 +6,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Range; import org.springframework.data.domain.SliceImpl; +import org.springframework.data.redis.connection.Limit; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; @@ -19,6 +21,8 @@ @Repository @RequiredArgsConstructor public class ChatMessageRepositoryImpl implements ChatMessageRepository { + private static final int COUNTER_DIGITS = 4; + private static final String SEPARATOR = "|"; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -28,11 +32,9 @@ public ChatMessage save(ChatMessage message) { String messageJson = objectMapper.writeValueAsString(message); String chatRoomKey = getChatRoomKey(message.getChatRoomId()); - redisTemplate.opsForZSet().add( // ZADD - chatRoomKey, - messageJson, - message.getChatId() - ); + String tsidKey = formatTsidKey(message.getChatId()); + + redisTemplate.opsForZSet().add(chatRoomKey, tsidKey + SEPARATOR + messageJson, 0); } catch (JsonProcessingException e) { log.error("Failed to save chat message: {}", message, e); throw new RuntimeException("Failed to save chat message", e); @@ -45,11 +47,7 @@ public ChatMessage save(ChatMessage message) { public List findRecentMessages(Long roomId, int limit) { String chatRoomKey = getChatRoomKey(roomId); - Set messageJsonSet = redisTemplate.opsForZSet().reverseRange( // ZREVRANGE - chatRoomKey, - 0, - limit - 1 - ); + Set messageJsonSet = redisTemplate.opsForZSet().reverseRangeByLex(chatRoomKey, Range.unbounded(), Limit.limit().count(limit)); return convertToMessages(messageJsonSet); } @@ -57,16 +55,15 @@ public List findRecentMessages(Long roomId, int limit) { @Override public SliceImpl findMessagesBefore(Long roomId, Long lastMessageId, int size) { String chatRoomKey = getChatRoomKey(roomId); + String tsidKey = formatTsidKey(lastMessageId); - Set messageJsonSet = redisTemplate.opsForZSet().reverseRangeByScore( // ZREVRANGEBYSCORE + Set messageJsonSet = redisTemplate.opsForZSet().reverseRangeByLex( chatRoomKey, - 0, // 최소값 - lastMessageId - 1, // 마지막으로 조회한 메시지 이전까지 - 0, // offset - size + 1 // size + 1 만큼 조회하여 다음 페이지 존재 여부 확인 + Range.of(Range.Bound.unbounded(), Range.Bound.exclusive(tsidKey)), + Limit.limit().count(size + 1) ); - List messages = convertToMessages(messageJsonSet); + boolean hasNext = messages.size() > size; if (hasNext) { @@ -76,6 +73,24 @@ public SliceImpl findMessagesBefore(Long roomId, Long lastMessageId return new SliceImpl<>(messages, PageRequest.of(0, size), hasNext); } + @Override + public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { + if (lastReadMessageId == null || lastReadMessageId < 0) { + throw new IllegalArgumentException("lastReadMessageId must not be null"); + } + + if (lastReadMessageId == 0L) { + return redisTemplate.opsForZSet().zCard(getChatRoomKey(roomId)); + } + + String chatRoomKey = getChatRoomKey(roomId); + String tsidKey = formatTsidKey(lastReadMessageId); + + Long totalCount = redisTemplate.opsForZSet().lexCount(chatRoomKey, Range.of(Range.Bound.inclusive(tsidKey), Range.Bound.unbounded())); + + return totalCount > 0 ? totalCount - 1 : 0; + } + /** * JSON 문자열 집합을 ChatMessage 객체 리스트로 변환합니다. * 변환 실패한 메시지는 무시됩니다. @@ -89,11 +104,12 @@ private List convertToMessages(Set messageJsonSet) { } return messageJsonSet.stream() - .map(json -> { + .map(value -> { try { + String json = value.substring(value.indexOf(SEPARATOR) + 1); return objectMapper.readValue(json, ChatMessage.class); } catch (JsonProcessingException e) { - log.error("Failed to parse chat message JSON: {}", json, e); + log.error("Failed to parse chat message JSON: {}", value, e); return null; } }) @@ -101,16 +117,6 @@ private List convertToMessages(Set messageJsonSet) { .toList(); } - public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { - String chatRoomKey = getChatRoomKey(roomId); - - return redisTemplate.opsForZSet().count( // ZCOUNT - chatRoomKey, - lastReadMessageId + 1, // 최소값 (lastReadMessageId 이후) - Double.POSITIVE_INFINITY // 최대값 - ); - } - /** * 채팅방의 조회용 Redis key를 생성합니다. * @@ -120,4 +126,17 @@ public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { private String getChatRoomKey(Long roomId) { return "chatroom:" + roomId + ":message"; } + + /** + * TSID를 lexicographical sorting이 가능한 형태의 문자열로 변환 + * format: {timestamp부분:16진수}:{counter부분:4자리} + */ + private String formatTsidKey(long tsid) { + String tsidStr = String.valueOf(tsid); + + String timestamp = tsidStr.substring(0, tsidStr.length() - COUNTER_DIGITS); + String counter = tsidStr.substring(tsidStr.length() - COUNTER_DIGITS); + + return timestamp + ":" + counter; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java index 1b92a465..6e90c780 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/message/service/ChatMessageService.java @@ -2,7 +2,7 @@ import kr.co.pennyway.common.annotation.DomainService; import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage; -import kr.co.pennyway.domain.common.redis.message.repository.ChatMessageRepositoryImpl; +import kr.co.pennyway.domain.common.redis.message.repository.ChatMessageRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Slice; @@ -13,10 +13,10 @@ @DomainService @RequiredArgsConstructor public class ChatMessageService { - private final ChatMessageRepositoryImpl chatMessageRepositoryImpl; + private final ChatMessageRepository chatMessageRepository; public ChatMessage create(ChatMessage chatMessage) { - return chatMessageRepositoryImpl.save(chatMessage); + return chatMessageRepository.save(chatMessage); } /** @@ -27,7 +27,7 @@ public ChatMessage create(ChatMessage chatMessage) { * @return 최근 시간 순으로 정렬된 최근 메시지 목록 */ public List readRecentMessages(Long roomId, int limit) { - return chatMessageRepositoryImpl.findRecentMessages(roomId, limit); + return chatMessageRepository.findRecentMessages(roomId, limit); } /** @@ -41,7 +41,7 @@ public List readRecentMessages(Long roomId, int limit) { * @return 페이징된 메시지 목록 */ public Slice readMessagesBefore(Long roomId, Long lastMessageId, int size) { - return chatMessageRepositoryImpl.findMessagesBefore(roomId, lastMessageId, size); + return chatMessageRepository.findMessagesBefore(roomId, lastMessageId, size); } /** @@ -52,6 +52,6 @@ public Slice readMessagesBefore(Long roomId, Long lastMessageId, in * @return 안 읽은 메시지 개수 */ public Long countUnreadMessages(Long roomId, Long lastReadMessageId) { - return chatMessageRepositoryImpl.countUnreadMessages(roomId, lastReadMessageId); + return chatMessageRepository.countUnreadMessages(roomId, lastReadMessageId); } } diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImplTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImplTest.java index 5f02585c..258d8e95 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImplTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/message/repository/ChatMessageRepositoryImplTest.java @@ -22,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -33,13 +34,14 @@ @Import({ChatMessageRepositoryImpl.class}) @ActiveProfiles("test") public class ChatMessageRepositoryImplTest extends ContainerRedisTestConfig { + private static final long CUSTOM_EPOCH = 1577836800000L; + @Autowired private RedisTemplate redisTemplate; @Autowired private ObjectMapper objectMapper; - // @Autowired private ChatMessageRepositoryImpl chatMessageRepositoryImpl; private ChatMessage chatMessage; @@ -49,7 +51,7 @@ void setUp() { chatMessageRepositoryImpl = new ChatMessageRepositoryImpl(redisTemplate, objectMapper); chatMessage = ChatMessageBuilder.builder() .chatRoomId(1L) - .chatId(1L) + .chatId(createChatId(1)) .content("Hello") .contentType(MessageContentType.TEXT) .categoryType(MessageCategoryType.NORMAL) @@ -90,10 +92,10 @@ void successFindRecentMessages() { @DisplayName("특정 메시지 이전의 메시지들을 페이징하여 조회한다") void successFindMessagesAfter() { // given - saveMessagesInOrder(1L, 10); + List messages = saveMessagesInOrder(1L, 10); // when - Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, 8L, 2); + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, messages.get(7).getChatId(), 2); // then assertAll( @@ -127,10 +129,10 @@ void successSaveAndFindEnumTypes() { @DisplayName("안 읽은 메시지 개수를 정확히 계산한다") void successCountUnreadMessages() { // given - saveMessagesInOrder(1L, 5); + List messages = saveMessagesInOrder(1L, 5); // when - Long unreadCount = chatMessageRepositoryImpl.countUnreadMessages(1L, 3L); + Long unreadCount = chatMessageRepositoryImpl.countUnreadMessages(1L, messages.get(2).getChatId()); // then assertEquals(2L, unreadCount, "마지막으로 읽은 메시지(ID: 3) 이후의 메시지 개수(4, 5)가 반환되어야 합니다"); @@ -177,10 +179,10 @@ void successFindFirstPage() { @DisplayName("BVA: 마지막 페이지(가장 오래된 메시지)까지 정상적으로 조회된다") void successFindLastPage() { // given - saveMessagesInOrder(1L, 5); + List messages = saveMessagesInOrder(1L, 5); // when - Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, 2L, 2); + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, messages.get(1).getChatId(), 2); // then assertAll( @@ -224,7 +226,7 @@ void returnEmptyForNonExistingRoom() { @DisplayName("존재하지 않는 메시지 ID로 페이징 조회 시 빈 Slice를 반환한다") void returnEmptySliceForNonExistingMessage() { // when - Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, 999L, 10); + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore(1L, createChatId(999), 10); // then assertAll( @@ -255,7 +257,7 @@ void successSortingWithSameTimestamp() { for (long i = 1; i <= messageCount; i++) { ChatMessage message = ChatMessageBuilder.builder() .chatRoomId(1L) - .chatId(i) + .chatId(createChatId(i)) .content("Message " + i) .contentType(MessageContentType.TEXT) .categoryType(MessageCategoryType.NORMAL) @@ -278,18 +280,88 @@ void successSortingWithSameTimestamp() { ); } - private void saveMessagesInOrder(Long roomId, int messageCount) { + @Test + @DisplayName("같은 밀리초에 생성된 메시지들 중 ID의 차이가 10의 자리 수 이내인 경우에도 조회에 성공한다.") + void successSortingWithCloseIds() { + // given + long timestamp = (System.currentTimeMillis() - CUSTOM_EPOCH); + int gap = 5; + + List messages = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(Long.parseLong(timestamp + String.format("%07d", i + gap))) // 5씩 차이나는 ID + .content("Message " + (i + 1)) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + ReflectionTestUtils.setField(message, "createdAt", LocalDateTime.now()); + messages.add(chatMessageRepositoryImpl.save(message)); + } + + // when + Slice messageSlice = chatMessageRepositoryImpl.findMessagesBefore( + 1L, + messages.get(1).getChatId(), // 2번째 메시지 ID + 1 + ); + + // then + assertAll( + () -> assertEquals(1, messageSlice.getContent().size(), "정확히 1개의 메시지가 조회되어야 합니다"), + () -> assertEquals("Message 1", messageSlice.getContent().get(0).getContent(), + "가장 첫 번째 메시지가 조회되어야 합니다"), + () -> assertFalse(messageSlice.hasNext(), "더 이전 메시지가 없어야 합니다") + ); + } + + @Test + @DisplayName("같은 밀리초에 생성된 메시지들 중 ID의 차이가 10의 자리 수 이내인 경우에도 읽지 않은 메시지 개수를 정확히 계산한다.") + void successCountUnreadMessagesWithCloseIds() { + // given + long timestamp = (System.currentTimeMillis() - CUSTOM_EPOCH); + int gap = 5; + + List messages = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + ChatMessage message = ChatMessageBuilder.builder() + .chatRoomId(1L) + .chatId(Long.parseLong(timestamp + String.format("%07d", i + gap))) // 5씩 차이나는 ID + .content("Message " + (i + 1)) + .contentType(MessageContentType.TEXT) + .categoryType(MessageCategoryType.NORMAL) + .sender(1L) + .build(); + ReflectionTestUtils.setField(message, "createdAt", LocalDateTime.now()); + log.info("message: {}", message); + messages.add(chatMessageRepositoryImpl.save(message)); + } + + // when + Long unreadCount = chatMessageRepositoryImpl.countUnreadMessages(1L, messages.get(8).getChatId()); + + // then + assertEquals(1L, unreadCount, "마지막으로 읽은 메시지(ID: 3) 이후의 메시지 개수(7)가 반환되어야 합니다"); + } + + private List saveMessagesInOrder(Long roomId, int messageCount) { + List messages = new ArrayList<>(); + for (long i = 1; i <= messageCount; i++) { ChatMessage message = ChatMessageBuilder.builder() .chatRoomId(roomId) - .chatId(i) + .chatId(createChatId(i)) .content("Message " + i) .contentType(MessageContentType.TEXT) .categoryType(MessageCategoryType.NORMAL) .sender(1L) .build(); - chatMessageRepositoryImpl.save(message); + messages.add(chatMessageRepositoryImpl.save(message)); } + + return messages; } @AfterEach @@ -299,4 +371,9 @@ void tearDown() { redisTemplate.delete(keys); } } + + private long createChatId(long i) { + long timestamp = (System.currentTimeMillis() - CUSTOM_EPOCH); + return Long.parseLong(timestamp + String.format("%07d", i)); + } }