Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -139,9 +139,13 @@ ResponseEntity<ChatInvitableUsersResponse> getInvitableUsers(
- 채팅방 참여자만 메시지를 조회할 수 있습니다.
- 일반 유저는 자신이 참여한 채팅방만 조회할 수 있습니다.
- 어드민은 모든 어드민 채팅방을 조회할 수 있습니다.
- `messageId`가 제공되면 해당 메시지가 포함된 페이지를 자동으로 계산하여 반환합니다.
검색 결과에서 특정 메시지 위치로 이동할 때 사용합니다.

## 에러
- FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다.
- FORBIDDEN_CHAT_ROOM_ACCESS (403): 채팅방에 접근할 권한이 없습니다 (messageId가 없는 일반 조회 시).
- NOT_FOUND_CHAT_ROOM (404): 채팅방을 찾을 수 없습니다.
messageId가 제공된 경우, 메시지가 유효하지 않거나 접근 권한이 없거나 가시성 경계를 벗어난 경우에도 모두 404로 통일 응답됩니다.
""")
@GetMapping("/rooms/{chatRoomId}")
ResponseEntity<ChatMessagePageResponse> getChatRoomMessages(
Expand All @@ -150,7 +154,8 @@ ResponseEntity<ChatMessagePageResponse> getChatRoomMessages(
@Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.")
@RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit,
@PathVariable(value = "chatRoomId") Integer chatRoomId,
@UserId Integer userId
@UserId Integer userId,
@RequestParam(name = "messageId", required = false) Integer messageId
);

@Operation(summary = "메시지를 전송한다.", description = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ public ResponseEntity<ChatMessagePageResponse> getChatRoomMessages(
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "limit", defaultValue = "20", required = false) Integer limit,
@PathVariable(value = "chatRoomId") Integer chatRoomId,
@UserId Integer userId
@UserId Integer userId,
@RequestParam(name = "messageId", required = false) Integer messageId
) {
ChatMessagePageResponse response = chatService.getMessages(userId, chatRoomId, page, limit);
ChatMessagePageResponse response = chatService.getMessages(userId, chatRoomId, page, limit, messageId);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public record ChatMessageMatchResult(

@Schema(description = "매칭된 메시지 전송 시간", example = "2025.12.19 23:21", requiredMode = REQUIRED)
@JsonFormat(pattern = "yyyy.MM.dd HH:mm")
LocalDateTime matchedMessageSentAt
LocalDateTime matchedMessageSentAt,

@Schema(description = "검색에 매칭된 메시지 ID", example = "42", requiredMode = REQUIRED)
Integer matchedMessageId
) {

public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMessage message) {
Expand All @@ -39,7 +42,8 @@ public static ChatMessageMatchResult from(ChatRoomSummaryResponse room, ChatMess
room.roomName(),
room.roomImageUrl(),
message.getContent(),
message.getCreatedAt()
message.getCreatedAt(),
message.getId()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand Down Expand Up @@ -35,13 +36,15 @@ List<UnreadMessageCount> countUnreadMessagesByChatRoomIdsAndUserId(
@Param("receiverId") Integer receiverId
);

// ORDER BY는 countNewerMessagesByChatRoomId의 WHERE 조건과
// 일치해야 함. 페이지 계산 정확도가 두 쿼리의 정렬 일관성에 의존함.
@Query("""
SELECT cm
FROM ChatMessage cm
JOIN FETCH cm.sender
WHERE cm.chatRoom.id = :chatRoomId
AND (:visibleMessageFrom IS NULL OR cm.createdAt > :visibleMessageFrom)
ORDER BY cm.createdAt DESC
ORDER BY cm.createdAt DESC, cm.id DESC
""")
Page<ChatMessage> findByChatRoomId(
@Param("chatRoomId") Integer chatRoomId,
Expand Down Expand Up @@ -140,6 +143,25 @@ List<ChatMessage> searchLatestMatchingMessagesByChatRoomIds(
@Param("keyword") String keyword
);

@Query("SELECT cm FROM ChatMessage cm JOIN FETCH cm.chatRoom WHERE cm.id = :messageId")
Optional<ChatMessage> findByIdWithChatRoom(@Param("messageId") Integer messageId);

// ORDER BY 기준이 findByChatRoomId와 일치해야 함 (createdAt DESC, id DESC).
// 페이지 계산 정확도가 두 쿼리의 정렬 일관성에 의존함.
@Query("""
SELECT COUNT(m)
FROM ChatMessage m
WHERE m.chatRoom.id = :chatRoomId
AND (m.createdAt > :createdAt OR (m.createdAt = :createdAt AND m.id > :messageId))
AND (:visibleMessageFrom IS NULL OR m.createdAt > :visibleMessageFrom)
""")
long countNewerMessagesByChatRoomId(
@Param("chatRoomId") Integer chatRoomId,
@Param("messageId") Integer messageId,
@Param("createdAt") LocalDateTime createdAt,
@Param("visibleMessageFrom") LocalDateTime visibleMessageFrom
);

@Query("""
SELECT COUNT(m)
FROM ChatMessage m
Expand Down
91 changes: 91 additions & 0 deletions src/main/java/gg/agit/konect/domain/chat/service/ChatService.java
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,22 @@ record SectionKey(Integer clubId, String clubName) {

@Transactional(readOnly = true)
public ChatMessagePageResponse getMessages(Integer userId, Integer roomId, Integer page, Integer limit) {
return getMessages(userId, roomId, page, limit, null);
}

@Transactional(readOnly = true)
public ChatMessagePageResponse getMessages(
Integer userId, Integer roomId, Integer page, Integer limit, Integer messageId
) {
ChatRoom room = chatRoomRepository.findById(roomId)
.orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM));
User user = userRepository.getById(userId);

if (messageId != null) {
ensureMessageLookupAccess(room, user, userId);
page = resolvePageForMessage(roomId, messageId, room, user, limit);
}

LocalDateTime readAt = LocalDateTime.now();

if (room.isDirectRoom()) {
Expand Down Expand Up @@ -1429,6 +1441,85 @@ private boolean isSystemAdminRoom(ChatRoom chatRoom) {
return userIds.contains(SYSTEM_ADMIN_ID);
}

/**
* messageId 조회 전 방 접근 권한을 검증한다.
* 권한 없음과 메시지 미존재를 구분할 수 없게 NOT_FOUND_CHAT_ROOM으로 통일하여
* 메시지 존재 여부 오라클을 방지한다.
*/
private void ensureMessageLookupAccess(ChatRoom room, User user, Integer userId) {
if (room.isDirectRoom()) {
boolean isMember = chatRoomMemberRepository
.findByChatRoomIdAndUserId(room.getId(), userId)
.isPresent();
if (!isMember && !(user.isAdmin() && isSystemAdminRoom(room))) {
throw CustomException.of(NOT_FOUND_CHAT_ROOM);
}
} else if (room.isClubGroupRoom()) {
try {
clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId);
} catch (CustomException e) {
// 동아리 멤버십 없음만 404로 변환, 다른 예외는 그대로 전파
if (e.getErrorCode() == NOT_FOUND_CLUB_MEMBER) {
throw CustomException.of(NOT_FOUND_CHAT_ROOM);
}
throw e;
}
Comment thread
dh2906 marked this conversation as resolved.
} else {
chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), userId)
.filter(member -> !member.hasLeft())
.orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM));
}
}

/**
* messageId가 가리키는 메시지가 포함된 페이지 번호를 계산한다.
* 가시성 검증 및 정보 누출 방지를 위해 동일한 에러 코드를 사용한다.
*/
private int resolvePageForMessage(
Integer roomId, Integer messageId, ChatRoom room, User user, int limit
) {
ChatMessage targetMessage = chatMessageRepository.findByIdWithChatRoom(messageId)
.orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM));

// 정보 누출 방지를 위해 동일한 에러 코드 사용
if (!targetMessage.getChatRoom().getId().equals(roomId)) {
throw CustomException.of(NOT_FOUND_CHAT_ROOM);
}

LocalDateTime visibleMessageFrom = resolveVisibleMessageFromPure(room, user);

if (visibleMessageFrom != null && !targetMessage.getCreatedAt().isAfter(visibleMessageFrom)) {
throw CustomException.of(NOT_FOUND_CHAT_ROOM);
}

// NOTE: count와 fetch 사이에 새 메시지가 삽입될 수 있으나,
// 호출부(getMessages)에서 응답에 타겟 메시지가 없으면 1회 재계산함
long newerCount = chatMessageRepository.countNewerMessagesByChatRoomId(
roomId, messageId, targetMessage.getCreatedAt(), visibleMessageFrom
);
return (int)(newerCount / limit) + 1;
Comment thread
dh2906 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* 채팅방 타입에 따른 메시지 가시성 기준 시간을 조회한다.
* 기존 getMessages() 흐름의 가시성 로직과 동일한 값을 반환하되,
* 방 복원 등 부수효과는 발생시키지 않는다.
*/
private LocalDateTime resolveVisibleMessageFromPure(ChatRoom room, User user) {
if (!room.isDirectRoom()) {
return null;
}

if (user.isAdmin() && isSystemAdminRoom(room)) {
List<ChatRoomMember> members = chatRoomMemberRepository.findByChatRoomId(room.getId());
return resolveAdminSystemRoomVisibleMessageFrom(members);
}

return chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())
.map(ChatRoomMember::getVisibleMessageFrom)
.orElse(null);
}

private boolean shouldDisplayAsOwnMessage(
User currentUser,
ChatMessage message,
Expand Down
Loading
Loading