diff --git a/build.gradle b/build.gradle index 780d8e0..600a39d 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/dog/dognation/api/ChatController.java b/src/main/java/com/dog/dognation/api/ChatController.java new file mode 100644 index 0000000..b43241f --- /dev/null +++ b/src/main/java/com/dog/dognation/api/ChatController.java @@ -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 history(@PathVariable Long spotId) { + return chatService.history(spotId); + } +} diff --git a/src/main/java/com/dog/dognation/api/dto/ChatDtos.java b/src/main/java/com/dog/dognation/api/dto/ChatDtos.java new file mode 100644 index 0000000..713703f --- /dev/null +++ b/src/main/java/com/dog/dognation/api/dto/ChatDtos.java @@ -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) { + } +} diff --git a/src/main/java/com/dog/dognation/chat/ChatSocketController.java b/src/main/java/com/dog/dognation/chat/ChatSocketController.java new file mode 100644 index 0000000..cedfcc2 --- /dev/null +++ b/src/main/java/com/dog/dognation/chat/ChatSocketController.java @@ -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); + } +} diff --git a/src/main/java/com/dog/dognation/chat/StompAuthChannelInterceptor.java b/src/main/java/com/dog/dognation/chat/StompAuthChannelInterceptor.java new file mode 100644 index 0000000..c29c7e7 --- /dev/null +++ b/src/main/java/com/dog/dognation/chat/StompAuthChannelInterceptor.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/chat/StompPrincipal.java b/src/main/java/com/dog/dognation/chat/StompPrincipal.java new file mode 100644 index 0000000..20d458a --- /dev/null +++ b/src/main/java/com/dog/dognation/chat/StompPrincipal.java @@ -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); + } +} diff --git a/src/main/java/com/dog/dognation/chat/WebSocketConfig.java b/src/main/java/com/dog/dognation/chat/WebSocketConfig.java new file mode 100644 index 0000000..58b2d7c --- /dev/null +++ b/src/main/java/com/dog/dognation/chat/WebSocketConfig.java @@ -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 인증 + } +} diff --git a/src/main/java/com/dog/dognation/config/WebConfig.java b/src/main/java/com/dog/dognation/config/WebConfig.java index d863d7c..beec26a 100644 --- a/src/main/java/com/dog/dognation/config/WebConfig.java +++ b/src/main/java/com/dog/dognation/config/WebConfig.java @@ -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/**"); diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatMessage.java b/src/main/java/com/dog/dognation/domain/chat/ChatMessage.java new file mode 100644 index 0000000..d04201d --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/chat/ChatMessage.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatMessageRepository.java b/src/main/java/com/dog/dognation/domain/chat/ChatMessageRepository.java new file mode 100644 index 0000000..ad68fed --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/chat/ChatMessageRepository.java @@ -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 { + + /** 방의 최근 메시지(오름차순). MVP: 최근 100건. */ + List findTop100ByRoomIdOrderByCreatedAtAsc(Long roomId); +} diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java b/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java new file mode 100644 index 0000000..6f7b73b --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/chat/ChatRoom.java @@ -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; + } +} diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java b/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java new file mode 100644 index 0000000..e8540b7 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/chat/ChatRoomRepository.java @@ -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 { + + Optional findBySpotId(Long spotId); +} diff --git a/src/main/java/com/dog/dognation/domain/chat/ChatService.java b/src/main/java/com/dog/dognation/domain/chat/ChatService.java new file mode 100644 index 0000000..12f3044 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/chat/ChatService.java @@ -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 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 toResponses(Long spotId, List messages) { + if (messages.isEmpty()) { + return List.of(); + } + List senderIds = messages.stream().map(ChatMessage::getSenderId).distinct().toList(); + Map 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()); + } +}