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-data-jpa'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
|
||||||
|
// 실시간 인앱 채팅 (STOMP over WebSocket)
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||||
|
|
||||||
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
|
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
|
||||||
implementation 'org.hibernate.orm:hibernate-spatial'
|
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) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
|
||||||
registry.addInterceptor(authInterceptor)
|
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 검사)
|
// 인가: 관리자 전용 (authInterceptor 다음에 실행되어 role 검사)
|
||||||
registry.addInterceptor(adminInterceptor)
|
registry.addInterceptor(adminInterceptor)
|
||||||
.addPathPatterns("/api/admin/**");
|
.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