Skip to content

Commit

Permalink
[feat #92] 채팅방 메시지 조회 API (#93)
Browse files Browse the repository at this point in the history
* [chore] : MongoDB 의존성 추가

* [chore] : docker-compose에 mongo 이미지 추가

* [feat] : messageType 변환 예외 구체화

* [feat] : chatMessage 엔티티 -> 몽고 DB collection으로 변경

* [feat] : 채팅 메시지 요청 dto 추가

* [feat] : 채팅 메시지 응답 dto 추가

* [feat] : 채팅 메시지<-> dto mapper 추가

* [feat] : 채팅 메시지 mongoRepository 추가

* [feat] : 채팅 메시지 저장, 조회 비즈니스 로직 추가

* [feat] : 채팅 메시지 저장, 조회 API 로직 추가

* [feat] : 채팅 메시지 조회 비즈니스 로직 테스트

* [test] : 테스트 컨테이너에 MongoDB 컨테이너 추가

* [test] : 채팅방 메시지 조회 통합 테스트 작성

* [rename] : chatMessage-> chatRoom으로 파일명 수정

* [chore] : PR시 배포 되도록 임시 수정

* [style] : 함수명 복수형으로 변경

* [feat] : dto validation 추가

* [chore] : cd 스크립트 원복
  • Loading branch information
hyun2371 authored Aug 29, 2024
1 parent 31305bd commit 65565f7
Show file tree
Hide file tree
Showing 15 changed files with 360 additions and 43 deletions.
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ dependencies {
// TestContainer
testImplementation "org.testcontainers:testcontainers:1.20.1"
testImplementation "org.testcontainers:junit-jupiter:1.20.1"
// mongoDB 컨테이너
testImplementation "org.testcontainers:mongodb:1.20.1"
// mysql 컨테이너
testImplementation "org.testcontainers:mysql:1.20.1"
// redis
Expand Down Expand Up @@ -139,6 +141,9 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// MongoDB
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb:3.1.8'
}

// Querydsl
Expand Down
14 changes: 13 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,16 @@ services:
container_name: gongmuin-redis
ports:
- "6379:6379"
restart: always
restart: always
mongodb:
image: mongo
container_name: mongodb
ports:
- "27017:27017"
environment:
- MONGO_INITDB_ROOT_USERNAME=rootuser
- MONGO_INITDB_ROOT_PASSWORD=rootpass
volumes:
- mongo:/data/db
volumes:
mongo:
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.dnd.gongmuin.chat.controller;

import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.dnd.gongmuin.chat.dto.ChatMessageRequest;
import com.dnd.gongmuin.chat.dto.ChatMessageResponse;
import com.dnd.gongmuin.chat.service.ChatRoomService;
import com.dnd.gongmuin.common.dto.PageResponse;
import com.dnd.gongmuin.member.domain.Member;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@Tag(name = "채팅방 API")
@RestController
@RequiredArgsConstructor
public class ChatRoomController {

private final ChatRoomService chatRoomService;

@Operation(summary = "채팅방 메시지 등록 임시 API", description = "웹소켓 연결 전 임시 API")
@PostMapping("/api/chat-messages/{chatRoomId}")
public ResponseEntity<Void> registerChatMessage(
@PathVariable Long chatRoomId,
@Valid @RequestBody ChatMessageRequest request,
@AuthenticationPrincipal Member member
) {
chatRoomService.saveChatMessage(request, chatRoomId, member.getId());
return new ResponseEntity<>(HttpStatus.CREATED);
}

@Operation(summary = "채팅방 메시지 조회 API", description = "채팅방 메시지를 최신순으로 페이징한다.")
@GetMapping("/api/chat-messages/{chatRoomId}")
public ResponseEntity<PageResponse<ChatMessageResponse>> getChatMessages(
@PathVariable("chatRoomId") Long chatRoomId,
Pageable pageable
) {
PageResponse<ChatMessageResponse> response =
chatRoomService.getChatMessages(chatRoomId, pageable);
return ResponseEntity.ok(response);
}
}
73 changes: 32 additions & 41 deletions src/main/java/com/dnd/gongmuin/chat/domain/ChatMessage.java
Original file line number Diff line number Diff line change
@@ -1,69 +1,60 @@
package com.dnd.gongmuin.chat.domain;

import static jakarta.persistence.ConstraintMode.*;
import static jakarta.persistence.EnumType.*;
import static jakarta.persistence.FetchType.*;
import static jakarta.persistence.GenerationType.*;
import static lombok.AccessLevel.*;

import com.dnd.gongmuin.common.entity.TimeBaseEntity;
import com.dnd.gongmuin.member.domain.Member;
import java.time.LocalDateTime;

import org.springframework.data.mongodb.core.mapping.Document;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Document(collection = "chat_messages")
@Getter
@NoArgsConstructor(access = PROTECTED)
public class ChatMessage extends TimeBaseEntity {
public class ChatMessage {

@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "chat_message_id")
private Long id;
private String id;

@Column(name = "content", nullable = false)
private String content;

@Column(name = "is_read", nullable = false)
private long chatRoomId;

private long memberId;

private Boolean isRead;

@Column(name = "media_url", nullable = false)
private String mediaUrl;

@Enumerated(STRING)
@Column(name = "type", nullable = false)
private MessageType type;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id",
nullable = false,
foreignKey = @ForeignKey(NO_CONSTRAINT))
private Member member;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "chat_room_id",
nullable = false,
foreignKey = @ForeignKey(NO_CONSTRAINT))
private ChatRoom chatRoom;
private LocalDateTime createdAt;

@Builder
public ChatMessage(String content, String mediaUrl, MessageType type, Member member,
ChatRoom chatRoom) {
this.isRead = false;
private ChatMessage(
String content,
long chatRoomId,
long memberId,
String mediaUrl,
MessageType type
) {
this.content = content;
this.chatRoomId = chatRoomId;
this.memberId = memberId;
this.mediaUrl = mediaUrl;
this.type = type;
this.member = member;
this.chatRoom = chatRoom;
this.isRead = false;
this.createdAt = LocalDateTime.now();
}

public static ChatMessage of(
String content,
long chatRoomId,
long memberId,
String mediaUrl,
MessageType type
) {
return new ChatMessage(content, chatRoomId, memberId, mediaUrl, type);
}
}
5 changes: 4 additions & 1 deletion src/main/java/com/dnd/gongmuin/chat/domain/MessageType.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import java.util.Arrays;

import com.dnd.gongmuin.chat.exception.ChatErrorCode;
import com.dnd.gongmuin.common.exception.runtime.ValidationException;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

Expand All @@ -19,7 +22,7 @@ public static MessageType of(String input) {
return Arrays.stream(values())
.filter(type -> type.isEqual(input))
.findAny()
.orElseThrow(IllegalArgumentException::new);
.orElseThrow(() -> new ValidationException(ChatErrorCode.INVALID_MESSAGE_TYPE));
}

private boolean isEqual(String input) {
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/com/dnd/gongmuin/chat/dto/ChatMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.dnd.gongmuin.chat.dto;

import com.dnd.gongmuin.chat.domain.ChatMessage;
import com.dnd.gongmuin.chat.domain.MessageType;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ChatMapper {

public static ChatMessageResponse toChatMessageResponse(
ChatMessage chatMessage
) {
return new ChatMessageResponse(
chatMessage.getMemberId(),
chatMessage.getChatRoomId(),
chatMessage.getContent(),
chatMessage.getType().getLabel(),
chatMessage.getMediaUrl()
);
}

public static ChatMessage toChatMessage(
ChatMessageRequest request,
long chatRoomId,
long memberId
) {
return ChatMessage.of(
request.content(),
chatRoomId,
memberId,
request.mediaUrl(),
MessageType.of(request.type())
);
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/dnd/gongmuin/chat/dto/ChatMessageRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.dnd.gongmuin.chat.dto;

import jakarta.validation.constraints.NotBlank;

public record ChatMessageRequest(
@NotBlank(message = "채팅 내용을 입력해주세요.")
String content,
@NotBlank(message = "채팅 타입을 입력해주세요.")
String type,
String mediaUrl
) {
}
10 changes: 10 additions & 0 deletions src/main/java/com/dnd/gongmuin/chat/dto/ChatMessageResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.dnd.gongmuin.chat.dto;

public record ChatMessageResponse(
Long memberId,
Long chatRoomId,
String content,
String type,
String mediaUrl
) {
}
16 changes: 16 additions & 0 deletions src/main/java/com/dnd/gongmuin/chat/exception/ChatErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dnd.gongmuin.chat.exception;

import com.dnd.gongmuin.common.exception.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ChatErrorCode implements ErrorCode {

INVALID_MESSAGE_TYPE("메시지 타입을 올바르게 입력해주세요.", "CH_001");

private final String message;
private final String code;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.dnd.gongmuin.chat.repository;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

import com.dnd.gongmuin.chat.domain.ChatMessage;

@Repository
public interface ChatMessageRepository extends MongoRepository<ChatMessage, String> {
Slice<ChatMessage> findByChatRoomIdOrderByCreatedAtDesc(long chatRoomId, Pageable pageable);
}
41 changes: 41 additions & 0 deletions src/main/java/com/dnd/gongmuin/chat/service/ChatRoomService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.dnd.gongmuin.chat.service;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.dnd.gongmuin.chat.dto.ChatMapper;
import com.dnd.gongmuin.chat.dto.ChatMessageRequest;
import com.dnd.gongmuin.chat.dto.ChatMessageResponse;
import com.dnd.gongmuin.chat.repository.ChatMessageRepository;
import com.dnd.gongmuin.common.dto.PageMapper;
import com.dnd.gongmuin.common.dto.PageResponse;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class ChatRoomService {

private final ChatMessageRepository chatMessageRepository;

@Transactional
public void saveChatMessage(
@Valid ChatMessageRequest request,
Long chatRoomId,
Long memberId
) {
chatMessageRepository.save(ChatMapper.toChatMessage(request, chatRoomId, memberId));
}

@Transactional(readOnly = true)
public PageResponse<ChatMessageResponse> getChatMessages(Long chatRoomId, Pageable pageable) {
Slice<ChatMessageResponse> responsePage = chatMessageRepository
.findByChatRoomIdOrderByCreatedAtDesc(chatRoomId, pageable)
.map(ChatMapper::toChatMessageResponse);
return PageMapper.toPageResponse(responsePage);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.dnd.gongmuin.chat.controller;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import java.util.List;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import com.dnd.gongmuin.chat.domain.ChatMessage;
import com.dnd.gongmuin.chat.repository.ChatMessageRepository;
import com.dnd.gongmuin.common.fixture.ChatMessageFixture;
import com.dnd.gongmuin.common.support.ApiTestSupport;

@DisplayName("[ChatMessage 통합 테스트]")
class ChatRoomControllerTest extends ApiTestSupport {

@Autowired
private ChatMessageRepository chatMessageRepository;

@DisplayName("[채팅방 아이디로 메시지를 조회할 수 있다.]")
@Test
void getChatMessages() throws Exception {
List<ChatMessage> chatMessages = chatMessageRepository.saveAll(List.of(
ChatMessageFixture.chatMessage(),
ChatMessageFixture.chatMessage()
));
mockMvc.perform(get("/api/chat-messages/{chatRoomId}", 1L)
.cookie(accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.size").value(2))
.andExpect(jsonPath("$.content[0].memberId").value(chatMessages.get(0).getMemberId()))
.andExpect(jsonPath("$.content[0].chatRoomId").value(chatMessages.get(0).getChatRoomId()))
.andExpect(jsonPath("$.content[0].content").value(chatMessages.get(0).getContent()))
.andExpect(jsonPath("$.content[0].type").value(chatMessages.get(0).getType().getLabel()))
.andExpect(jsonPath("$.content[0].mediaUrl").value(chatMessages.get(0).getMediaUrl()));
}
}
Loading

0 comments on commit 65565f7

Please sign in to comment.