Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;

import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageService;

import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.back.web7_9_codecrete_be.domain.chats.controller;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatReadResponse;
import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageReadService;
import com.back.web7_9_codecrete_be.global.rsData.RsData;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

@Tag(name = "Chat", description = "채팅 메시지 조회 API")
@RestController
@RequestMapping("/api/v1/chats")
@RequiredArgsConstructor
public class ChatMessageReadController {

private final ChatMessageReadService chatMessageReadService;

@Operation(
summary = "채팅 메시지 조회 (cursor 기반 무한 스크롤)",
description = """
채팅 메시지를 cursor(before) 기준으로 조회합니다.

- before 미전달 시: 최신 메시지를 조회합니다.
- before 전달 시: 해당 cursor 이전의 메시지를 조회합니다.
"""
)
@GetMapping("/{concertId}/messages")
public RsData<List<ChatReadResponse>> getMessages(
@Parameter(
description = "공연 ID",
example = "1",
required = true
)
@PathVariable Long concertId,

@Parameter(
description = """
조회 기준 cursor (Redis Stream ID).
해당 값이 주어지면, 그 이전의 채팅 메시지를 조회합니다.
""",
example = "1734940012345-0",
required = false
)
@RequestParam(required = false) String before,

@Parameter(
description = "한 번에 조회할 메시지 개수 (기본값: 20)",
example = "20",
required = false
)
@RequestParam(required = false) Integer size
) {
return RsData.success(
chatMessageReadService.getMessages(concertId, before, size)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

@Tag(name = "Chat Room", description = "공연 채팅방 입장 및 퇴장 API")
@Tag(name = "Chat", description = "공연 채팅방 입장 API")
@RestController
@RequestMapping("/api/v1/chat-room")
@RequiredArgsConstructor
Expand All @@ -34,8 +34,13 @@ public class ChatRoomController {
Redis Stream 기반 과거 채팅과 실시간 채팅을 수신합니다.
""" )
@ApiResponse( responseCode = "200", description = "채팅방 입장에 성공하였습니다." )
@ApiResponse( responseCode = "403", description = "로그인이 필요합니다." )
@ApiResponse( responseCode = "403", description = "채팅 가능한 기간이 아닙니다." )
@ApiResponse(
responseCode = "403",
description = """
- 로그인이 필요합니다.
- 채팅 가능한 기간이 아닙니다.
"""
)
@ApiResponse( responseCode = "404", description = "콘서트가 존재하지 않습니다." )
@PostMapping("/concert/{concertId}/join")
public RsData<Void> joinChatRoom(@PathVariable Long concertId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse;
import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -27,7 +27,7 @@ public class ChatStompDocsController {
```
ws://localhost:8080/ws-chat
or
wss://www.naeconcertbutakhae.shop/ws-chat
wss://api.naeconcertbutakhae.shop/ws-chat
```

#### 2️⃣ SEND Destination
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.back.web7_9_codecrete_be.domain.chats.dto;
package com.back.web7_9_codecrete_be.domain.chats.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.back.web7_9_codecrete_be.domain.chats.dto;
package com.back.web7_9_codecrete_be.domain.chats.dto.response;

import java.time.LocalDateTime;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.back.web7_9_codecrete_be.domain.chats.dto.response;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "채팅 메시지 조회 응답")
public class ChatReadResponse {

@Schema(description = "Redis Stream 메시지 ID (before로 전달하면, 해당 메시지 이전 메시지를 조회할 수 있습니다)", example = "1700000123456-0")
private String messageId;

@Schema(description = "공연 ID", example = "1")
private Long concertId;

@Schema(description = "발신자 ID", example = "2")
private Long senderId;

@Schema(description = "발신자 닉네임", example = "테스트 유저")
private String senderName;

@Schema(description = "메시지 내용", example = "안녕하세요")
private String content;

@Schema(description = "전송 시각")
private LocalDateTime sentDate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.back.web7_9_codecrete_be.domain.chats.dto.response;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChatUserCache {

private Long userId;
private String nickname;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.back.web7_9_codecrete_be.domain.chats.repository;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import org.springframework.data.domain.Range;
import org.springframework.data.redis.connection.Limit;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.StreamRecords;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse;
import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatReadResponse;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class ChatStreamRepository {

private final RedisTemplate<String, Object> redisTemplate;

private String streamKey(Long concertId) {
return "chat:stream:" + concertId;
}

/** 저장 */
public void save(ChatMessageResponse message) {
String key = streamKey(message.getConcertId());

Map<String, String> fields = new HashMap<>();
fields.put("concertId", message.getConcertId().toString());
fields.put("senderId", message.getSenderId().toString());
fields.put("senderName", message.getSenderName());
fields.put("content", message.getContent());
fields.put("sentDate", message.getSentDate().toString());

redisTemplate.opsForStream().add(
StreamRecords.newRecord()
.in(key)
.ofMap(fields)
);
}

public boolean hasTtl(Long concertId) {
Long ttl = redisTemplate.getExpire(streamKey(concertId));
return ttl != null && ttl >= 0;
}

public void setTtl(Long concertId, Duration ttl) {
redisTemplate.expire(streamKey(concertId), ttl);
}

/** 조회 */
// 채팅방 진입시 가장 최신 메시지 size개 조회
public List<ChatReadResponse> findLatest(Long concertId, int size) {

List<MapRecord<String, Object, Object>> records =
redisTemplate.opsForStream().reverseRange(
streamKey(concertId),
Range.unbounded(),
Limit.limit().count(size)
);

return toResponses(records);
}

// cursor(before) 기반 과거 메시지 조회
// beforeMessageId보다 이전에 있던 메시지들 중 최신 size개 조회
public List<ChatReadResponse> findBefore(
Long concertId,
String beforeMessageId,
int size
) {
String exclusiveBeforeId = exclusiveBefore(beforeMessageId);

List<MapRecord<String, Object, Object>> records =
redisTemplate.opsForStream().reverseRange(
streamKey(concertId),
Range.leftOpen("0-0", exclusiveBeforeId),
Limit.limit().count(size)
);

return toResponses(records);
}

private List<ChatReadResponse> toResponses(
List<MapRecord<String, Object, Object>> records
) {
if (records == null || records.isEmpty()) {
return List.of();
}

return records.stream()
.map(record -> {
Map<Object, Object> v = record.getValue();

return new ChatReadResponse(
record.getId().getValue(),
Long.valueOf(v.get("concertId").toString()),
Long.valueOf(v.get("senderId").toString()),
v.get("senderName").toString(),
v.get("content").toString(),
LocalDateTime.parse(v.get("sentDate").toString())
);
})
.filter(Objects::nonNull)
.toList();
}

private String exclusiveBefore(String messageId) {
String[] parts = messageId.split("-");
long time = Long.parseLong(parts[0]); // 밀리초 타임스탬프
long seq = Long.parseLong(parts[1]); // 같은 밀리초 내 순번
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

같은 시간 내에서도 순번을 정하신것 좋은 방법인 것 같습니다


if (seq > 0) {
return time + "-" + (seq - 1);
}

// seq == 0 인 경우, timestamp를 1 감소
return (time - 1) + "-0";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.back.web7_9_codecrete_be.domain.chats.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatReadResponse;
import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class ChatMessageReadService {

private static final int DEFAULT_SIZE = 20;
private static final int MAX_SIZE = 50;

private final ChatStreamRepository chatStreamRepository;

public List<ChatReadResponse> getMessages(
Long concertId,
String before,
Integer size
) {
int limit = Math.min(
size != null ? size : DEFAULT_SIZE,
MAX_SIZE
);

// 최초 진입
if (before == null || before.isBlank()) {
return chatStreamRepository.findLatest(concertId, limit);
}

// 이전 메시지 조회 (무한 스크롤)
return chatStreamRepository.findBefore(concertId, before, limit);
}
}
Loading