Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6588e2a
feat: 채팅 도메인 관련 생성자 추가
Gyuhyeok99 Jul 27, 2025
5135613
feat: 채팅방 목록 조회 응답 관련 dto 생성
Gyuhyeok99 Jul 27, 2025
e1f20d6
feat: 채팅방 관련 레포지토리 생성
Gyuhyeok99 Jul 27, 2025
449049f
feat: 채팅방 목록 조회 관련 서비스 구현
Gyuhyeok99 Jul 27, 2025
14e4c6c
feat: 채팅방 컨트롤러 생성
Gyuhyeok99 Jul 27, 2025
6948995
test: 채팅 관련 fixture 생성
Gyuhyeok99 Jul 27, 2025
541747a
test: 채팅방 목록 조회 테스트 작성
Gyuhyeok99 Jul 27, 2025
67507b1
feat: 채팅 관련 레포지토리 추가
Gyuhyeok99 Jul 27, 2025
cdadb15
feat: 채팅 응답 관련 dto 생성
Gyuhyeok99 Jul 27, 2025
be5240a
feat: 채팅 내역 조회 서비스 구현
Gyuhyeok99 Jul 27, 2025
a437240
feat: 채팅 내역 조회 컨트롤러 생성
Gyuhyeok99 Jul 27, 2025
5ea6eaf
test: 채팅 첨부파일 관련 fixture 생성
Gyuhyeok99 Jul 27, 2025
a7df7bb
test: 채팅 내역 조회 테스트 작성
Gyuhyeok99 Jul 27, 2025
df2b913
feat: 채팅 읽음 처리 관련 레포지토리 추가
Gyuhyeok99 Jul 27, 2025
c6b5115
feat: 채팅 읽음 처리 서비스 구현
Gyuhyeok99 Jul 27, 2025
a89dd5f
feat: 채팅 읽음 처리 컨트롤러 생성
Gyuhyeok99 Jul 27, 2025
fa58816
test: 채팅 읽음 처리 테스트 작성
Gyuhyeok99 Jul 27, 2025
8ea1cca
style: reformat code 적용
Gyuhyeok99 Jul 27, 2025
fe46db3
refactor: ChatMessageRepository로 함수 위치 변경
Gyuhyeok99 Jul 27, 2025
747c635
feat: 멘토링 승인 시 채팅방 생성 서비스 구현
Gyuhyeok99 Jul 27, 2025
320d5f5
test: 멘토링 승인 시 채팅방 생성 테스트 작성
Gyuhyeok99 Jul 27, 2025
0189452
feat: 배치사이즈 추가
Gyuhyeok99 Jul 27, 2025
fa1f474
chore: todo 주석 추가
Gyuhyeok99 Jul 27, 2025
3241b0b
Merge remote-tracking branch 'origin/develop' into feat/404-chat-api
Gyuhyeok99 Jul 30, 2025
2d9acb5
refactor: JPQL 대신 Spring Data JPA 표준에 맞게 변경
Gyuhyeok99 Jul 30, 2025
d9ad86b
refactor: DB와 영속성 컨텍스트 간 불일치 방지를 위한 옵션 추가
Gyuhyeok99 Jul 30, 2025
02def7d
refactor: findPartner 함수 최적화
Gyuhyeok99 Jul 30, 2025
a255b3a
refactor: 중복 검증 제거
Gyuhyeok99 Jul 30, 2025
a2e8b1f
refactor: 에러 메시지 통일
Gyuhyeok99 Jul 30, 2025
78e9ad2
refactor: 컨벤션에 맞게 함수 위치 수정
Gyuhyeok99 Jul 30, 2025
8f698cb
refactor: 채팅방 생성 관련 로직 제거
Gyuhyeok99 Jul 30, 2025
b535f6b
style: 컨벤션에 맞게 개행 추가
Gyuhyeok99 Jul 30, 2025
82219b7
style: 컨벤션에 맞게 개행 추가
Gyuhyeok99 Jul 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.example.solidconnection.chat.controller;

import com.example.solidconnection.chat.dto.ChatMessageResponse;
import com.example.solidconnection.chat.dto.ChatRoomListResponse;
import com.example.solidconnection.chat.service.ChatService;
import com.example.solidconnection.common.dto.SliceResponse;
import com.example.solidconnection.common.resolver.AuthorizedUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/chats")
public class ChatController {

private final ChatService chatService;

@GetMapping("/rooms")
public ResponseEntity<ChatRoomListResponse> getChatRooms(
@AuthorizedUser long siteUserId
) {
ChatRoomListResponse chatRoomListResponse = chatService.getChatRooms(siteUserId);
return ResponseEntity.ok(chatRoomListResponse);
}

@GetMapping("/rooms/{room-id}")
public ResponseEntity<SliceResponse<ChatMessageResponse>> getChatMessages(
@AuthorizedUser long siteUserId,
@PathVariable("room-id") Long roomId,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
) {
SliceResponse<ChatMessageResponse> response = chatService.getChatMessages(siteUserId, roomId, pageable);
return ResponseEntity.ok(response);
}

@PutMapping("/rooms/{room-id}/read")
public ResponseEntity<Void> markChatMessagesAsRead(
@AuthorizedUser long siteUserId,
@PathVariable("room-id") Long roomId
) {
chatService.markChatMessagesAsRead(siteUserId, roomId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,14 @@ public class ChatAttachment extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY)
private ChatMessage chatMessage;

public ChatAttachment(boolean isImage, String url, String thumbnailUrl, ChatMessage chatMessage) {
this.isImage = isImage;
this.url = url;
this.thumbnailUrl = thumbnailUrl;
this.chatMessage = chatMessage;
if (chatMessage != null) {
chatMessage.getChatAttachments().add(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,14 @@ public class ChatMessage extends BaseEntity {
private ChatRoom chatRoom;

@OneToMany(mappedBy = "chatMessage", cascade = CascadeType.ALL)
private List<ChatAttachment> chatAttachments = new ArrayList<>();
private final List<ChatAttachment> chatAttachments = new ArrayList<>();

public ChatMessage(String content, long senderId, ChatRoom chatRoom) {
this.content = content;
this.senderId = senderId;
this.chatRoom = chatRoom;
if (chatRoom != null) {
chatRoom.getChatMessages().add(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,12 @@ public class ChatParticipant extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY)
private ChatRoom chatRoom;

public ChatParticipant(long siteUserId, ChatRoom chatRoom) {
this.siteUserId = siteUserId;
this.chatRoom = chatRoom;
if (chatRoom != null) {
chatRoom.getChatParticipants().add(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,9 @@ public class ChatReadStatus extends BaseEntity {

@Column(name = "chat_participant_id")
private long chatParticipantId;

public ChatReadStatus(long chatRoomId, long chatParticipantId) {
this.chatRoomId = chatRoomId;
this.chatParticipantId = chatParticipantId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;

@Entity
@Getter
Expand All @@ -25,8 +26,13 @@ public class ChatRoom extends BaseEntity {
private boolean isGroup = false;

@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL)
private List<ChatParticipant> chatParticipants = new ArrayList<>();
@BatchSize(size = 10)
private final List<ChatParticipant> chatParticipants = new ArrayList<>();

@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL)
private List<ChatMessage> chatMessages = new ArrayList<>();
private final List<ChatMessage> chatMessages = new ArrayList<>();

public ChatRoom(boolean isGroup) {
this.isGroup = isGroup;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.solidconnection.chat.dto;

import java.time.ZonedDateTime;

public record ChatAttachmentResponse(
long id,
boolean isImage,
String url,
String thumbnailUrl,
ZonedDateTime createdAt
) {

public static ChatAttachmentResponse of(long id, boolean isImage, String url,
String thumbnailUrl, ZonedDateTime createdAt) {
return new ChatAttachmentResponse(id, isImage, url, thumbnailUrl, createdAt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.solidconnection.chat.dto;

import java.time.ZonedDateTime;
import java.util.List;

public record ChatMessageResponse(
long id,
String content,
long senderId,
ZonedDateTime createdAt,
List<ChatAttachmentResponse> attachments
) {

public static ChatMessageResponse of(long id, String content, long senderId,
ZonedDateTime createdAt, List<ChatAttachmentResponse> attachments) {
return new ChatMessageResponse(id, content, senderId, createdAt, attachments);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.chat.dto;

public record ChatParticipantResponse(
long partnerId,
String nickname,
String profileUrl
) {

public static ChatParticipantResponse of(long partnerId, String nickname, String profileUrl) {
return new ChatParticipantResponse(partnerId, nickname, profileUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.chat.dto;

import java.util.List;

public record ChatRoomListResponse(
List<ChatRoomResponse> chatRooms
) {

public static ChatRoomListResponse of(List<ChatRoomResponse> chatRooms) {
return new ChatRoomListResponse(chatRooms);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.solidconnection.chat.dto;

import java.time.ZonedDateTime;

public record ChatRoomResponse(
long id,
String lastChatMessage,
ZonedDateTime lastReceivedTime,
ChatParticipantResponse partner,
long unReadCount
) {

public static ChatRoomResponse of(
long id,
String lastChatMessage,
ZonedDateTime lastReceivedTime,
ChatParticipantResponse partner,
long unReadCount
) {
return new ChatRoomResponse(id, lastChatMessage, lastReceivedTime, partner, unReadCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatAttachment;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ChatAttachmentRepository extends JpaRepository<ChatAttachment, Long> {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatMessage;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {

@Query("""
SELECT cm FROM ChatMessage cm
LEFT JOIN FETCH cm.chatAttachments
WHERE cm.chatRoom.id = :roomId
ORDER BY cm.createdAt DESC
""")
Slice<ChatMessage> findByRoomIdWithPaging(@Param("roomId") long roomId, Pageable pageable);
Comment on lines +13 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

채팅 메세지를 최신순으로 정렬하여 응답한다면,

예를 들어

  • 반갑습니다 (10:00)
  • 저도 반갑습니다 (10:10)
  • 날씨가 덥네요 (10:20)

라는 메세지가

  1. 날씨가 덥네요 (10:20)
  2. 저도 반갑습니다 (10:10)
  3. 반갑습니다 (10:00)

이런식으로 보이지 않을까요..?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 전 보통의 채팅 메시지는 최신것을 보여주고 이전 내용을 보고싶을 때 위로 올린다고 생각해서 최신순으로 주지만, 프론트에서 reverse를 해서 화면에 띄운다고 생각했었습니다!
혹시 어떤식으로 주는 게 좋을까요..? 성혁님과 수연님과 회의 때 이부분에 대해서 이야기를 하지 않았어서 제가 그냥 혼자 생각해서 구현을 해버렸네요 @whqtker @lsy1307

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 ... 저도 이 부분은 프론트의 영역이라고 생각합니다 ! 저희는 단순히 최신 순으로 정렬된 채팅을 전달해주면 된다고 생각합니다.


Optional<ChatMessage> findFirstByChatRoomIdOrderByCreatedAtDesc(long chatRoomId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatParticipant;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ChatParticipantRepository extends JpaRepository<ChatParticipant, Long> {

boolean existsByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId);

Optional<ChatParticipant> findByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatReadStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ChatReadStatusRepository extends JpaRepository<ChatReadStatus, Long> {

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = """
INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, updated_at)
VALUES (:chatRoomId, :chatParticipantId, NOW(6), NOW(6))
ON DUPLICATE KEY UPDATE updated_at = NOW(6)
""", nativeQuery = true)
void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatRoom;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {

@Query("""
SELECT cr FROM ChatRoom cr
JOIN cr.chatParticipants cp
WHERE cp.siteUserId = :userId AND cr.isGroup = false
ORDER BY (
SELECT MAX(cm.createdAt)
FROM ChatMessage cm
WHERE cm.chatRoom = cr
) DESC NULLS LAST
""")
List<ChatRoom> findOneOnOneChatRoomsByUserId(@Param("userId") long userId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 조회는 Slice 로 페이지네이션 응답을 할 필요는 없나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하! 회의때는 채팅방이 많이 개설되지 않을 것이라고 이야기를 해서 굳이 Slice를 하지 말자! 고 이야기가 나왔었는데 이 부분도 Slice를 적용할까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하~ 일리가 있네요, 페이지네이션 반영 안하셔도 됩니다!👍


@Query("""
SELECT COUNT(cm) FROM ChatMessage cm
LEFT JOIN ChatReadStatus crs ON crs.chatRoomId = cm.chatRoom.id
AND crs.chatParticipantId = (
SELECT cp.id FROM ChatParticipant cp
WHERE cp.chatRoom.id = :chatRoomId
AND cp.siteUserId = :userId
)
WHERE cm.chatRoom.id = :chatRoomId
AND cm.senderId != :userId
AND (crs.updatedAt IS NULL OR cm.createdAt > crs.updatedAt)
""")
long countUnreadMessages(@Param("chatRoomId") long chatRoomId, @Param("userId") long userId);
Comment on lines +23 to +35
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 지식 없이 코드만 보는 입장에서는,
33번 라인의 AND cm.senderId != :userId 가 없어도 되지 않을까 싶어요!
왜냐하면 내가 메세지를 보냈다는 것은, 그 메세지를 당연히 읽었다는 것이니까요.

정리해서 다시 말하자면 이렇습니다!

AS-IS

  • 내가 참여한 어떤 채팅방의 채팅 메세지들 중에서
  • 내가 보내지 않은 채팅 메세지이면서
  • 확인하지 않은 메세지의 수를 count

TO-BE
"내가 보낸 메세지"는 이미 내가 확인했을 것이므로, 두번째 조건이 없어도 될 것 같다.

정책상 제가 모르는 부분이 있다면 말씀해주세요 🙇🏻‍♀️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 좋은 이야기 감사합니다!
AND cm.senderId != :userId가 없어도 잘 동작할 거 같습니다!
그런데 일반적으로 "미읽음 메시지 수"는 다른 사람이 보낸 메시지 중 읽지 않은 것만 카운트한다고 생각하여 비즈니스 로직상 있는게 나을 거 같다고 생각했는데 그냥 지울까요?

리뷰 작성하면서 AND cm.senderId != :userId 필터링 조건이 있으면 성능이 더 좋은가?라는 생각도 들긴 했는데 이건 테스트를 해보지 않는 이상 잘 모르겠어서 확신이 안드네요 😅

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일반적으로 "미읽음 메시지 수"는 다른 사람이 보낸 메시지 중 읽지 않은 것만 카운트한다고 생각하여 비즈니스 로직상 있는게 나을 거 같다고 생각했는데 그냥 지울까요?

다시 생각해보니 스키마 배경지식 없이, 일반적으로 생각한다면 다른 사람이 보낸 것만 카운트하는게 맞겠네요😅

}
Loading
Loading