feat: 실시간 인앱 채팅 (STOMP over WebSocket)
- 스팟별 채팅방(/topic/spots/{id}), 전송 /app/spots/{id}/chat, 엔드포인트 /ws
- STOMP CONNECT Bearer 토큰 인증(ChannelInterceptor) → 미인증 전송 무시
- ChatRoom/ChatMessage 도메인, 방 없으면 최초 메시지 시 생성, 발신자 닉네임 포함
- GET /api/chat/spots/{id}/messages 히스토리(보호), 전역 WebSocket 브로커(인메모리)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,9 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
// 실시간 인앱 채팅 (STOMP over WebSocket)
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
|
||||
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
|
||||
implementation 'org.hibernate.orm:hibernate-spatial'
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.dog.dognation.api;
|
||||
|
||||
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
|
||||
import com.dog.dognation.domain.chat.ChatService;
|
||||
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.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 스팟 채팅 히스토리 (로그인 필요). 실시간 송수신은 WebSocket(/ws). */
|
||||
@RestController
|
||||
@RequestMapping("/api/chat")
|
||||
public class ChatController {
|
||||
|
||||
private final ChatService chatService;
|
||||
|
||||
public ChatController(ChatService chatService) {
|
||||
this.chatService = chatService;
|
||||
}
|
||||
|
||||
@GetMapping("/spots/{spotId}/messages")
|
||||
public List<ChatMessageResponse> history(@PathVariable Long spotId) {
|
||||
return chatService.history(spotId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.dog.dognation.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public final class ChatDtos {
|
||||
|
||||
private ChatDtos() {
|
||||
}
|
||||
|
||||
public record ChatMessageResponse(
|
||||
Long id,
|
||||
Long spotId,
|
||||
Long senderId,
|
||||
String senderNickname,
|
||||
String content,
|
||||
OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
/** WebSocket 전송 페이로드. */
|
||||
public record ChatSendRequest(
|
||||
@NotBlank(message = "내용이 비어있습니다.")
|
||||
@Size(max = 1000, message = "메시지는 1000자 이하여야 합니다.")
|
||||
String content) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
|
||||
import com.dog.dognation.api.dto.ChatDtos.ChatSendRequest;
|
||||
import com.dog.dognation.domain.chat.ChatService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/** 스팟 채팅 메시지 수신 → 저장 → 구독자에게 브로드캐스트. */
|
||||
@Controller
|
||||
public class ChatSocketController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatSocketController.class);
|
||||
|
||||
private final ChatService chatService;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public ChatSocketController(ChatService chatService, SimpMessagingTemplate messagingTemplate) {
|
||||
this.chatService = chatService;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
@MessageMapping("/spots/{spotId}/chat")
|
||||
public void send(@DestinationVariable Long spotId,
|
||||
@Valid @Payload ChatSendRequest req,
|
||||
Principal principal) {
|
||||
if (!(principal instanceof StompPrincipal user)) {
|
||||
log.warn("[chat] 미인증 전송 시도 spot={}", spotId);
|
||||
return; // 인증되지 않은 연결은 무시
|
||||
}
|
||||
ChatMessageResponse res = chatService.post(spotId, user.userId(), req.content());
|
||||
messagingTemplate.convertAndSend("/topic/spots/" + spotId, res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import com.dog.dognation.auth.AuthService;
|
||||
import com.dog.dognation.auth.SessionUser;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.support.ChannelInterceptor;
|
||||
import org.springframework.messaging.support.MessageHeaderAccessor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* STOMP CONNECT 시 Authorization: Bearer 토큰을 검증해 인증 주체를 세션에 심는다.
|
||||
* 이후 SEND 프레임에서 Principal 로 발신자를 식별한다.
|
||||
*/
|
||||
@Component
|
||||
public class StompAuthChannelInterceptor implements ChannelInterceptor {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public StompAuthChannelInterceptor(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||
StompHeaderAccessor accessor =
|
||||
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||
String bearer = accessor.getFirstNativeHeader("Authorization");
|
||||
String token = (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null;
|
||||
SessionUser user = authService.getSession(token);
|
||||
if (user != null) {
|
||||
accessor.setUser(new StompPrincipal(String.valueOf(user.id()), user.role()));
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/** WebSocket 세션의 인증 주체. name 에 userId 문자열을 담는다. */
|
||||
public record StompPrincipal(String name, String role) implements Principal {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Long userId() {
|
||||
return Long.valueOf(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
/**
|
||||
* STOMP over WebSocket 설정.
|
||||
* 엔드포인트 : /ws
|
||||
* 구독 : /topic/spots/{spotId}
|
||||
* 전송 : /app/spots/{spotId}/chat
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private final StompAuthChannelInterceptor authChannelInterceptor;
|
||||
|
||||
public WebSocketConfig(StompAuthChannelInterceptor authChannelInterceptor) {
|
||||
this.authChannelInterceptor = authChannelInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns(
|
||||
"http://localhost:9000", "http://localhost",
|
||||
"capacitor://localhost", "ionic://localhost");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry registry) {
|
||||
registry.enableSimpleBroker("/topic"); // 인메모리 브로커
|
||||
registry.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
registration.interceptors(authChannelInterceptor); // CONNECT 인증
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**", "/api/dogs/**");
|
||||
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/admin/**",
|
||||
"/api/dogs/**", "/api/chat/**");
|
||||
// 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사)
|
||||
registry.addInterceptor(adminInterceptor)
|
||||
.addPathPatterns("/api/admin/**");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.dog.dognation.domain.chat;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "chat_messages")
|
||||
public class ChatMessage {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "room_id", nullable = false)
|
||||
private Long roomId;
|
||||
|
||||
@Column(name = "sender_id", nullable = false)
|
||||
private Long senderId;
|
||||
|
||||
@Column(nullable = false, length = 1000)
|
||||
private String content;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt = OffsetDateTime.now();
|
||||
|
||||
protected ChatMessage() {
|
||||
}
|
||||
|
||||
public ChatMessage(Long roomId, Long senderId, String content) {
|
||||
this.roomId = roomId;
|
||||
this.senderId = senderId;
|
||||
this.content = content;
|
||||
this.createdAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getSenderId() {
|
||||
return senderId;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.dog.dognation.domain.chat;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
|
||||
|
||||
/** 방의 최근 메시지(오름차순). MVP: 최근 100건. */
|
||||
List<ChatMessage> findTop100ByRoomIdOrderByCreatedAtAsc(Long roomId);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.dog.dognation.domain.chat;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/** 스팟 기반 오픈 채팅방. 스팟당 1개, 최초 메시지 시 생성. */
|
||||
@Entity
|
||||
@Table(name = "chat_rooms")
|
||||
public class ChatRoom {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "spot_id")
|
||||
private Long spotId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt = OffsetDateTime.now();
|
||||
|
||||
protected ChatRoom() {
|
||||
}
|
||||
|
||||
public ChatRoom(Long spotId) {
|
||||
this.spotId = spotId;
|
||||
this.createdAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getSpotId() {
|
||||
return spotId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.dog.dognation.domain.chat;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
|
||||
|
||||
Optional<ChatRoom> findBySpotId(Long spotId);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.dog.dognation.domain.chat;
|
||||
|
||||
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
|
||||
import com.dog.dognation.common.exception.ApiException;
|
||||
import com.dog.dognation.domain.spot.SpotRepository;
|
||||
import com.dog.dognation.domain.user.User;
|
||||
import com.dog.dognation.domain.user.UserRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ChatService {
|
||||
|
||||
private final ChatRoomRepository roomRepository;
|
||||
private final ChatMessageRepository messageRepository;
|
||||
private final SpotRepository spotRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public ChatService(ChatRoomRepository roomRepository,
|
||||
ChatMessageRepository messageRepository,
|
||||
SpotRepository spotRepository,
|
||||
UserRepository userRepository) {
|
||||
this.roomRepository = roomRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.spotRepository = spotRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/** 스팟 채팅방 확보(없으면 생성). */
|
||||
@Transactional
|
||||
public ChatRoom roomForSpot(Long spotId) {
|
||||
if (!spotRepository.existsById(spotId)) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "스팟을 찾을 수 없습니다.");
|
||||
}
|
||||
return roomRepository.findBySpotId(spotId)
|
||||
.orElseGet(() -> roomRepository.save(new ChatRoom(spotId)));
|
||||
}
|
||||
|
||||
/** 스팟 채팅 최근 메시지. 방이 없으면 빈 목록. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<ChatMessageResponse> history(Long spotId) {
|
||||
return roomRepository.findBySpotId(spotId)
|
||||
.map(room -> toResponses(spotId, messageRepository.findTop100ByRoomIdOrderByCreatedAtAsc(room.getId())))
|
||||
.orElseGet(List::of);
|
||||
}
|
||||
|
||||
/** 메시지 저장 후 브로드캐스트용 응답 반환. */
|
||||
@Transactional
|
||||
public ChatMessageResponse post(Long spotId, Long senderId, String content) {
|
||||
ChatRoom room = roomForSpot(spotId);
|
||||
ChatMessage saved = messageRepository.save(new ChatMessage(room.getId(), senderId, content));
|
||||
String nickname = userRepository.findById(senderId).map(User::getNickname).orElse("알 수 없음");
|
||||
return new ChatMessageResponse(saved.getId(), spotId, senderId, nickname,
|
||||
saved.getContent(), saved.getCreatedAt());
|
||||
}
|
||||
|
||||
private List<ChatMessageResponse> toResponses(Long spotId, List<ChatMessage> messages) {
|
||||
if (messages.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> senderIds = messages.stream().map(ChatMessage::getSenderId).distinct().toList();
|
||||
Map<Long, String> nicknames = userRepository.findAllById(senderIds).stream()
|
||||
.collect(Collectors.toMap(User::getId, User::getNickname, (a, b) -> a));
|
||||
return messages.stream()
|
||||
.map(m -> new ChatMessageResponse(m.getId(), spotId, m.getSenderId(),
|
||||
nicknames.getOrDefault(m.getSenderId(), "알 수 없음"),
|
||||
m.getContent(), m.getCreatedAt()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user