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
@@ -0,0 +1,45 @@
package com.cagong.receiptpowerserver.domain.chat;

import com.cagong.receiptpowerserver.domain.member.Member;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class) // [!!] createdAt ์ž๋™ ์ƒ์„ฑ์„ ์œ„ํ•ด ํ•„์š”
public class ChatMessage {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id", nullable = false)
private ChatRoom chatRoom;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id", nullable = false)
private Member sender; // ๋ณด๋‚ธ ์‚ฌ๋žŒ

@Column(nullable = false, length = 1000) // ๋ฉ”์‹œ์ง€ ๊ธธ์ด ์ œํ•œ (ํ•„์š”์‹œ ์กฐ์ ˆ)
private String message; // ๋ฉ”์‹œ์ง€ ๋‚ด์šฉ

@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt; // ๋ณด๋‚ธ ์‹œ๊ฐ„

@Builder
public ChatMessage(ChatRoom chatRoom, Member sender, String message) {
this.chatRoom = chatRoom;
this.sender = sender;
this.message = message;
}
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,90 @@
// chat/ChatMessageController.java

package com.cagong.receiptpowerserver.domain.chat;

import com.cagong.receiptpowerserver.domain.chat.dto.ChatMessageRequest;
import com.cagong.receiptpowerserver.domain.chat.dto.ChatMessageResponse; // [!!] ์‘๋‹ต DTO ์ž„ํฌํŠธ
// [!!] ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์˜์กด์„ฑ ๋ชจ๋‘ ์ œ๊ฑฐ๋จ
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

/**
* [๋ฆฌํŒฉํ† ๋ง] WebSocket ๋ฉ”์‹œ์ง€ ์ค‘๊ณ„(Routing) ์—ญํ• ๋งŒ ๋‹ด๋‹นํ•˜๋Š” ์ปจํŠธ๋กค๋Ÿฌ.
* ๋ชจ๋“  ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ ChatMessageService์— ์œ„์ž„ํ•ฉ๋‹ˆ๋‹ค.
*/
@Controller
@RequiredArgsConstructor
public class ChatMessageController {

private final SimpMessageSendingOperations messagingTemplate;
private final ChatMessageService chatMessageService; // [!!] ์‹ ๊ทœ ์„œ๋น„์Šค ์ฃผ์ž…

// [!!] ๋ชจ๋“  Repository ์˜์กด์„ฑ ์ œ๊ฑฐ๋จ

/**
* /pub/chat/message ๊ฒฝ๋กœ๋กœ ์˜ค๋Š” ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ
*/
@MessageMapping("/chat/message")
public void message(@Payload ChatMessageRequest message, StompHeaderAccessor headerAccessor) {

// [ํ•ต์‹ฌ] STOMP ์„ธ์…˜ ์†์„ฑ์—์„œ StompHandler๊ฐ€ ์ €์žฅํ•ด ๋‘” ์‚ฌ์šฉ์ž ์ด๋ฆ„์„ ์ง์ ‘ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
// 1. ์„ธ์…˜์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ (์ธ์ฆ ์ •๋ณด)
String senderName = (String) headerAccessor.getSessionAttributes().get("username");
Long senderId = (Long) headerAccessor.getSessionAttributes().get("userId");
Long roomId = message.getRoomId();

// ๋งŒ์•ฝ ๋น„์ •์ƒ์ ์ธ ์ ‘๊ทผ์œผ๋กœ senderName์ด ์—†๋‹ค๋ฉด, ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ๋ฅผ ์ค‘๋‹จํ•ฉ๋‹ˆ๋‹ค.
if (senderName == null) {
return;
if (senderName == null || senderId == null) {
return; // ๋น„์ •์ƒ ์ ‘๊ทผ ์ฐจ๋‹จ
}
message.setSender(senderName); // (ENTER, QUIT ๋ฉ”์‹œ์ง€์šฉ)

// 2. TALK ๋ฉ”์‹œ์ง€์ธ ๊ฒฝ์šฐ, ์„œ๋น„์Šค์— ์ €์žฅ์„ ์œ„์ž„
if (ChatMessageRequest.MessageType.TALK.equals(message.getType())) {

message.setSender(senderName);
// ์„œ๋น„์Šค ํ˜ธ์ถœ: DB์— ๋ฉ”์‹œ์ง€ ์ €์žฅ ํ›„, ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์šฉ DTO ๋ฐ˜ํ™˜
ChatMessageResponse responseDto = chatMessageService.saveMessage(message, senderId, senderName);

if (ChatMessageRequest.MessageType.ENTER.equals(message.getType())) {
message.setMessage(senderName + "๋‹˜์ด ์ž…์žฅํ•˜์…จ์Šต๋‹ˆ๋‹ค.");
// ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ผ๊ด€๋œ ์‘๋‹ต(ChatMessageResponse)์„ ๋ฐ›๋„๋ก DTO๋กœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…
messagingTemplate.convertAndSend("/sub/chat/room/" + roomId, responseDto);

} else {
// 3. ENTER, QUIT ๋ฉ”์‹œ์ง€๋Š” DB ์ €์žฅ ์—†์ด ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…
// (๊ธฐ์กด ChatMessageRequest ํฌ๋งท ๊ทธ๋Œ€๋กœ ์ „์†ก)
if (ChatMessageRequest.MessageType.ENTER.equals(message.getType())) {
message.setMessage(senderName + "๋‹˜์ด ์ž…์žฅํ•˜์…จ์Šต๋‹ˆ๋‹ค.");
// ๋น„์ •์ƒ ์ข…๋ฃŒ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ์„ธ์…˜์— roomId ์ €์žฅ
headerAccessor.getSessionAttributes().put("roomId", roomId);
} else if (ChatMessageRequest.MessageType.QUIT.equals(message.getType())) {
message.setMessage(senderName + "๋‹˜์ด ํ‡ด์žฅํ•˜์…จ์Šต๋‹ˆ๋‹ค.");
}

messagingTemplate.convertAndSend("/sub/chat/room/" + roomId, message);
}

messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
// 4. ์ฐธ์—ฌ์ž ์ˆ˜ ์—…๋ฐ์ดํŠธ ์ด๋ฒคํŠธ ์ „์†ก (ENTER, QUIT ์ผ ๋•Œ๋งŒ)
if (ChatMessageRequest.MessageType.ENTER.equals(message.getType()) ||
ChatMessageRequest.MessageType.QUIT.equals(message.getType())) {

// ์„œ๋น„์Šค์˜ ํ—ฌํผ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
chatMessageService.broadcastParticipantUpdate(roomId);
}
}

/**
* ๋น„์ •์ƒ ์ข…๋ฃŒ(Disconnect) ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ
* (๋กœ์ง์„ ChatMessageService์— ์œ„์ž„)
*/
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

String username = (String) headerAccessor.getSessionAttributes().get("username");
Long roomId = (Long) headerAccessor.getSessionAttributes().get("roomId");

// ์„œ๋น„์Šค ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
chatMessageService.handleDisconnect(username, roomId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.cagong.receiptpowerserver.domain.chat;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {

// [!!] โœ… 2๋ฒˆ (๋กœ๊ทธ ์กฐํšŒ) ๊ธฐ๋Šฅ์„ ์œ„ํ•œ ํ•ต์‹ฌ ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ
// ํŠน์ • ์ฑ„ํŒ…๋ฐฉ์˜ ๋ฉ”์‹œ์ง€๋ฅผ ์ƒ์„ฑ ์‹œ๊ฐ„(createdAt) ์˜ค๋ฆ„์ฐจ์ˆœ์œผ๋กœ ๋ชจ๋‘ ์กฐํšŒ
List<ChatMessage> findByChatRoomIdOrderByCreatedAtAsc(Long roomId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.cagong.receiptpowerserver.domain.chat;

import com.cagong.receiptpowerserver.domain.chat.dto.ChatMessageRequest;
import com.cagong.receiptpowerserver.domain.chat.dto.ChatMessageResponse;
import com.cagong.receiptpowerserver.domain.member.Member;
import com.cagong.receiptpowerserver.domain.member.MemberRepository;
import com.cagong.receiptpowerserver.exception.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;

/**
* [์‹ ๊ทœ] ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€ ๊ด€๋ จ ๋ชจ๋“  ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ „๋‹ดํ•˜๋Š” ์„œ๋น„์Šค
* (WebSocket, HTTP API, ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง ํฌํ•จ)
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChatMessageService {

// --- ์˜์กด์„ฑ ์ฃผ์ž… ---
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomRepository chatRoomRepository;
private final MemberRepository memberRepository;
private final ChatParticipantRepository chatParticipantRepository;
private final SimpMessageSendingOperations messagingTemplate;

/**
* [WebSocket] ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€ ์ €์žฅ ๋ฐ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…
* (ChatMessageController์˜ TALK ํƒ€์ž… ๋กœ์ง ์ด๊ด€)
*
* @param request ํด๋ผ์ด์–ธํŠธ ์š”์ฒญ DTO
* @param senderId JWT ํ† ํฐ์—์„œ ์ถ”์ถœํ•œ ๋ฐœ์‹ ์ž ID
* @param senderName JWT ํ† ํฐ์—์„œ ์ถ”์ถœํ•œ ๋ฐœ์‹ ์ž ์ด๋ฆ„
* @return ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์— ์‚ฌ์šฉ๋  ChatMessageResponse DTO
*/
@Transactional
public ChatMessageResponse saveMessage(ChatMessageRequest request, Long senderId, String senderName) {
// 1. ๋ฐœ์‹ ์ž(Member) ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ
Member sender = memberRepository.findById(senderId)
.orElseThrow(() -> new NotFoundException("Member not found: " + senderId));

// 2. ์ฑ„ํŒ…๋ฐฉ(ChatRoom) ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ
ChatRoom chatRoom = chatRoomRepository.findById(request.getRoomId())
.orElseThrow(() -> new NotFoundException("Chat room not found: " + request.getRoomId()));

// 3. ๋ฉ”์‹œ์ง€ ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ
ChatMessage chatMessage = ChatMessage.builder()
.chatRoom(chatRoom)
.sender(sender)
.message(request.getMessage())
.build();

// 4. DB์— ์ €์žฅ (INSERT)
ChatMessage savedMessage = chatMessageRepository.save(chatMessage);

// 5. ์—”ํ‹ฐํ‹ฐ๋ฅผ DTO๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜
return toChatMessageResponse(savedMessage);
}

/**
* [HTTP API] ํŠน์ • ์ฑ„ํŒ…๋ฐฉ์˜ ๊ณผ๊ฑฐ ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก ์กฐํšŒ
* (ChatRoomService์—์„œ ์ด๊ด€)
*
* @param roomId ์ฑ„ํŒ…๋ฐฉ ID
* @return ๋ฉ”์‹œ์ง€ DTO ๋ฆฌ์ŠคํŠธ
*/
public List<ChatMessageResponse> getMessages(Long roomId) {
if (!chatRoomRepository.existsById(roomId)) {
throw new NotFoundException("Chat room not found: " + roomId);
}

List<ChatMessage> messages = chatMessageRepository.findByChatRoomIdOrderByCreatedAtAsc(roomId);

return messages.stream()
.map(this::toChatMessageResponse)
.toList();
}

/**
* [WebSocket Event] ๋น„์ •์ƒ ์ข…๋ฃŒ ์ฒ˜๋ฆฌ
* (ChatMessageController์˜ handleWebSocketDisconnectListener ๋กœ์ง ์ด๊ด€)
*
* @param username ์„ธ์…˜์— ์ €์žฅ๋œ ์‚ฌ์šฉ์ž ์ด๋ฆ„
* @param roomId ์„ธ์…˜์— ์ €์žฅ๋œ ๋ฐฉ ID
*/
public void handleDisconnect(String username, Long roomId) {
if (username != null && roomId != null) {
// 1. QUIT ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ
ChatMessageRequest quitMessage = new ChatMessageRequest();
quitMessage.setType(ChatMessageRequest.MessageType.QUIT);
quitMessage.setSender(username);
quitMessage.setRoomId(roomId);
quitMessage.setMessage(username + "๋‹˜์ด ํ‡ด์žฅํ•˜์…จ์Šต๋‹ˆ๋‹ค.");

// 2. ํ•ด๋‹น ์ฑ„ํŒ…๋ฐฉ์— ํ‡ด์žฅ ๋ฉ”์‹œ์ง€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…
messagingTemplate.convertAndSend("/sub/chat/room/" + roomId, quitMessage);

// 3. ๋น„์ •์ƒ ์ข…๋ฃŒ ์‹œ์—๋„ ์ธ์›์ˆ˜ ์—…๋ฐ์ดํŠธ ์ด๋ฒคํŠธ ์ „์†ก
broadcastParticipantUpdate(roomId);
}
}

/**
* [Helper] ์ฐธ์—ฌ์ž ์ˆ˜ ์—…๋ฐ์ดํŠธ ์ด๋ฒคํŠธ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…
* (ChatMessageController์˜ sendParticipantUpdate ๋กœ์ง ์ด๊ด€)
*
* @param roomId ๋ฐฉ ID
*/
public void broadcastParticipantUpdate(Long roomId) {
if (roomId == null) return;

// DB์—์„œ ํ˜„์žฌ ์ธ์›์ˆ˜๋ฅผ ์กฐํšŒ
long currentParticipants = chatParticipantRepository.countByChatRoom_Id(roomId);

Map<String, Object> updateEvent = Map.of(
"type", "PARTICIPANT_UPDATE",
"roomId", roomId,
"currentParticipants", currentParticipants
);

messagingTemplate.convertAndSend("/sub/chat/room/" + roomId, updateEvent);
}

/**
* [Helper] ChatMessage ์—”ํ‹ฐํ‹ฐ๋ฅผ ChatMessageResponse DTO๋กœ ๋ณ€ํ™˜
* (ChatRoomService์˜ ํ—ฌํผ ๋ฉ”์„œ๋“œ ์ด๊ด€)
*/
private ChatMessageResponse toChatMessageResponse(ChatMessage entity) {
Member sender = entity.getSender();
Long senderId = (sender != null) ? sender.getId() : null;
String senderName = (sender != null) ? sender.getUsername() : "์•Œ ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž"; // getUsername() ๋˜๋Š” getNickname()

return ChatMessageResponse.builder()
.id(entity.getId())
.roomId(entity.getChatRoom().getId())
.senderId(senderId)
.senderName(senderName)
.message(entity.getMessage())
.timestamp(entity.getCreatedAt())
.build();
}
}
Loading
Loading