diff --git a/chat-service/build.gradle b/chat-service/build.gradle index 2054976..692935e 100644 --- a/chat-service/build.gradle +++ b/chat-service/build.gradle @@ -1,15 +1,28 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation 'org.springframework.cloud:spring-cloud-config-client' implementation 'org.springframework.boot:spring-boot-starter-actuator' + // dynamodb implementation platform('software.amazon.awssdk:bom:2.20.85') implementation 'software.amazon.awssdk:dynamodb-enhanced' + // kafka & websocket + implementation 'org.springframework.kafka:spring-kafka' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + testImplementation 'org.springframework.kafka:spring-kafka-test' + runtimeOnly 'org.postgresql:postgresql:42.7.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } \ No newline at end of file diff --git a/chat-service/src/main/java/com/rljj/chatservice/ChatServiceApplication.java b/chat-service/src/main/java/com/rljj/chatservice/ChatServiceApplication.java index 7ddcd3e..56cc697 100644 --- a/chat-service/src/main/java/com/rljj/chatservice/ChatServiceApplication.java +++ b/chat-service/src/main/java/com/rljj/chatservice/ChatServiceApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @EnableDiscoveryClient @SpringBootApplication public class ChatServiceApplication { diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/controller/ChatController.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..91acc4c --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/controller/ChatController.java @@ -0,0 +1,55 @@ +package com.rljj.chatservice.domain.chat.controller; + +import com.rljj.chatservice.domain.chat.dto.ChatMessageListResponse; +import com.rljj.chatservice.domain.chat.dto.ChatRoomListResponse; +import com.rljj.chatservice.domain.chat.dto.ChatRoomRequest; +import com.rljj.chatservice.domain.chat.dto.Message; +import com.rljj.chatservice.domain.chat.service.ChatService; +import com.rljj.chatservice.global.util.SecurityUtils; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RequestMapping("/api/chat") +@RestController +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + // 채팅방 만들기 + @PostMapping("/rooms") + public ResponseEntity createChatRoom(@RequestBody @Valid final ChatRoomRequest request) { + chatService.makeChatRoom(SecurityUtils.getMemberId(), request); + return ResponseEntity.ok().build(); + } + + // 채팅방 리스트 조회하기 + @GetMapping("/rooms") + public ResponseEntity getChatRoomList() { + ChatRoomListResponse chatRoomList = chatService.getChatRoomList(SecurityUtils.getMemberId()); + return ResponseEntity.ok(chatRoomList); + } + + // TODO 채팅방 삭제하기 + // TODO 채팅방 및 메시지 내역 페이징 방법 정하기 + + // 채팅메시지 내역 조회하기 + @GetMapping("/rooms/{roomId}") + public ResponseEntity getChatMessageList(@PathVariable("roomId") Long roomId) { + ChatMessageListResponse chatMessageList = chatService.getChatMessageList(roomId, SecurityUtils.getMemberId()); + return ResponseEntity.ok(chatMessageList); + } + + // 채팅메시지 보내기 + @MessageMapping("/messages") + public void sendMessage(@Valid Message message, @Header("Authorization") final String accessToken) { + chatService.sendMessage(message, accessToken); + } + +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/controller/ChatMessageController.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/controller/ChatMessageController.java deleted file mode 100644 index 63f2418..0000000 --- a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/controller/ChatMessageController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.rljj.chatservice.domain.chat.controller; - -import com.rljj.chatservice.domain.chat.respository.ChatMessageRepository; -import com.rljj.chatservice.domain.chat.model.ChatMessage; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/chat") -public class ChatMessageController { - - private final ChatMessageRepository chatMessageRepository; - - @PostMapping("/chatMessage") - public ResponseEntity saveChatMessage(@RequestBody ChatMessage chatMessage) { - chatMessageRepository.saveChatMessage(chatMessage); - return ResponseEntity.ok().build(); - } - - @GetMapping("/chatRoom/{chatRoomId}") - public List getChatRoomById(@PathVariable("chatRoomId") Long chatRoomId) { - return chatMessageRepository.getChatRoomById(chatRoomId); - } - -} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatMessageDto.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatMessageDto.java new file mode 100644 index 0000000..b07a0f6 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatMessageDto.java @@ -0,0 +1,36 @@ +package com.rljj.chatservice.domain.chat.dto; + +import com.rljj.chatservice.domain.chat.model.ChatMessage; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class ChatMessageDto { + private String chatMessageId; + + private Long chatRoomId; + private LocalDateTime sendDate; + + private Long senderId; + private String message; + + private boolean isMine; + + public ChatMessageDto(ChatMessage chatMessage, Long memberId) { + this.chatMessageId = chatMessage.getMessageId(); + this.chatRoomId = chatMessage.getChatRoomId(); + this.sendDate = chatMessage.getCreatedAt(); + this.senderId = chatMessage.getSenderId(); + this.message = chatMessage.getMessage(); + this.isMine = chatMessage.getSenderId().equals(memberId); // 내가 보낸 메시지 여부 + } + + public static List from(List chatMessageList, Long memberId) { + return chatMessageList.stream() + .map(chatMessage -> new ChatMessageDto(chatMessage, memberId)) + .toList(); + } + +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatMessageListResponse.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatMessageListResponse.java new file mode 100644 index 0000000..5ede0f8 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatMessageListResponse.java @@ -0,0 +1,12 @@ +package com.rljj.chatservice.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class ChatMessageListResponse { + private List messages; +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomDto.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomDto.java new file mode 100644 index 0000000..d0bd4f7 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomDto.java @@ -0,0 +1,34 @@ +package com.rljj.chatservice.domain.chat.dto; + +import com.rljj.switchswitchentity.chat.ChatRoom; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class ChatRoomDto { + + private Long chatRoomId; + private Long memberId; // 현재 멤버 ID + private Long chatPartnerId; // 채팅 상대방 ID + private Long chipPostId; // 게시물 ID +// private String latestMessage; +// private LocalDateTime latestMessageCreatedAt; + + public ChatRoomDto(ChatRoom chatRoom, Long memberId) { + this.chatRoomId = chatRoom.getId(); + this.memberId = memberId; + this.chatPartnerId = chatRoom.getAuthor().getId().equals(memberId) + ? chatRoom.getRequester().getId() // 내가 author 상대방 = requester + : chatRoom.getAuthor().getId(); + this.chipPostId = chatRoom.getChipPost().getId(); + } + + public static List from(List chatRoomList, Long memberId) { + return chatRoomList.stream() + .map(chatRoom -> new ChatRoomDto(chatRoom, memberId)) + .toList(); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomListResponse.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomListResponse.java new file mode 100644 index 0000000..f30d8fe --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomListResponse.java @@ -0,0 +1,12 @@ +package com.rljj.chatservice.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class ChatRoomListResponse { + private List rooms; +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomRequest.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomRequest.java new file mode 100644 index 0000000..908eebd --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/ChatRoomRequest.java @@ -0,0 +1,28 @@ +package com.rljj.chatservice.domain.chat.dto; + +import com.rljj.switchswitchentity.chat.ChatRoom; +import com.rljj.switchswitchentity.chip.chippost.ChipPost; +import com.rljj.switchswitchentity.member.Member; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoomRequest { + + @NotNull + private Long chipPostId; // 게시물 id + @NotNull + private Long requesterId; // 거래요청건 멤버 id + + public ChatRoom toEntity(Long memberId) { + return ChatRoom.create( + ChipPost.builder().id(chipPostId).build(), + Member.builder().id(memberId).build(), + Member.builder().id(requesterId).build() + ); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/Message.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/Message.java new file mode 100644 index 0000000..27a2ff2 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/dto/Message.java @@ -0,0 +1,49 @@ +package com.rljj.chatservice.domain.chat.dto; + +import com.rljj.chatservice.domain.chat.model.ChatMessage; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Message implements Serializable { + private String id; + + @NotNull + private Long chatRoomId; + + @NotNull + private String message; + + private Long senderId; + //private Long senderNickname; + + private LocalDateTime createdAt; + + // Method to set id, senderId, and createdAt at once + public void setMessageDetails(Long senderId, LocalDateTime createdAt) { + this.id = UUID.randomUUID().toString(); + this.senderId = senderId; + this.createdAt = createdAt; + } + + // Message를 ChatMessage로 변환하는 메서드 + public ChatMessage toChatMessage() { + return ChatMessage.builder() + .chatRoomId(this.chatRoomId) + .createdAt(this.createdAt) + .messageId(this.id) + .senderId(this.senderId) + .message(this.message) + .build(); + } + +} + + diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatMessageRepository.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatMessageRepository.java index d2e2ea9..e0b5f43 100644 --- a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatMessageRepository.java +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatMessageRepository.java @@ -24,11 +24,11 @@ public void saveChatMessage(ChatMessage chatMessage) { chatMessageTable.putItem(chatMessage); } - public List getChatRoomById(Long chatRoomId) { + public List findByChatRoomId(Long chatRoomId) { // Partition Key 조건 생성 QueryConditional queryConditional = QueryConditional.keyEqualTo( - Key.builder().partitionValue(chatRoomId).build() - ); + Key.builder().partitionValue(chatRoomId) + .build()); // Query 실행 return chatMessageTable.query(r -> r @@ -37,4 +37,90 @@ public List getChatRoomById(Long chatRoomId) { .limit(10) // 최대 10개의 데이터 가져오기 ).items().stream().toList(); } + + /*public PaginatedResult findByChatRoomIdWithPagination(Long chatRoomId, LocalDateTime lastEvaluatedKey, int pageSize) { + QueryEnhancedRequest.Builder queryBuilder = QueryEnhancedRequest.builder() + .queryConditional(QueryConditional.keyEqualTo(Key.builder().partitionValue(chatRoomId).build())) + .limit(pageSize) // 한 번에 가져올 메시지 개수 제한 + .scanIndexForward(false); // 최신 메시지부터 정렬 (내림차순) + + // 마지막으로 가져온 메시지가 있으면 ExclusiveStartKey 설정 + if (lastEvaluatedKey != null) { + queryBuilder.exclusiveStartKey(Map.of( + "chatroom_id", AttributeValue.builder().n(String.valueOf(chatRoomId)).build(), + "created_at", AttributeValue.builder().s(lastEvaluatedKey.toString()).build() + )); + } + + // 쿼리 실행 + PageIterable results = chatMessageTable.query(queryBuilder.build()); + + // 첫 번째 페이지 가져오기 + Iterator> iterator = results.iterator(); + if (!iterator.hasNext()) { + return new PaginatedResult<>(Collections.emptyList(), null); + } + + Page page = iterator.next(); + List messages = page.items(); + + // 다음 페이지 조회를 위한 Key 설정 + Map lastKeyMap = page.lastEvaluatedKey(); + LocalDateTime nextLastKey = null; + if (lastKeyMap != null && lastKeyMap.containsKey("created_at")) { + nextLastKey = LocalDateTime.parse(lastKeyMap.get("created_at").s()); + } + + return new PaginatedResult<>(messages, nextLastKey); + } + + @Getter + @AllArgsConstructor + public class PaginatedResult { + private List items; // 현재 페이지의 메시지 리스트 + private LocalDateTime lastEvaluatedKey; // 다음 페이지 조회를 위한 키 + + *//* + 1. 첫 페이지 요청 + lastEvaluatedKey = null 로 요청 + 최신 메시지 pageSize개 조회 + 2. 스크롤하여 추가 데이터 요청 + 마지막으로 가져온 lastEvaluatedKey 값 전달 + 3. 더 이상 데이터 없으면 lastEvaluated는 null + 클라이언트는 추가 요청 중단 + *//* + }*/ + + /*// 여러 chatRoomId에 대해 배치 조회 (BatchGetItem 사용) + public List findByChatRoomIds(List chatRoomIds) { + // BatchGetItem 요청 생성 + List> keys = new ArrayList<>(); + + chatRoomIds.forEach(chatRoomId -> { + Map key = new HashMap<>(); + key.put("chatRoomId", AttributeValue.builder().n(chatRoomId.toString()).build()); + keys.add(key); + }); + + // KeysAndAttributes에 keys 추가 + Map requestItems = new HashMap<>(); + requestItems.put("chat_messages", KeysAndAttributes.builder().keys(keys).build()); + + BatchGetItemRequest batchGetItemRequest = BatchGetItemRequest.builder() + .requestItems(requestItems) + .build(); + + // BatchGetItemResponse로 변경 + BatchGetItemResponse result = dynamoDbEnhancedClient.batchGetItem(batchGetItemRequest); + + return result.responses().get("chat_messages").stream() + .map(item -> convertToChatMessage(item)) + .collect(Collectors.toList()); + } + + // 응답을 ChatMessage 객체로 변환하는 메서드 (필요한 변환 로직 추가) + private ChatMessage convertToChatMessage(Map item) { + // 변환 로직 추가 + return new ChatMessage(); // 적절한 변환 로직을 작성하세요 + }*/ } diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatRoomRepository.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatRoomRepository.java new file mode 100644 index 0000000..8020888 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/respository/ChatRoomRepository.java @@ -0,0 +1,21 @@ +package com.rljj.chatservice.domain.chat.respository; + +import com.rljj.switchswitchentity.chat.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + + @Query("SELECT c FROM ChatRoom c " + + "JOIN FETCH c.author " + + "JOIN FETCH c.requester " + + "JOIN FETCH c.chipPost " + + "WHERE c.author.id = :memberId OR c.requester.id = :memberId") + List findChatRoomsByMemberId(@Param("memberId") Long memberId); + +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/ChatService.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/ChatService.java new file mode 100644 index 0000000..75cfc16 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/ChatService.java @@ -0,0 +1,76 @@ +package com.rljj.chatservice.domain.chat.service; + +import com.rljj.chatservice.domain.chat.dto.*; +import com.rljj.chatservice.domain.chat.model.ChatMessage; +import com.rljj.chatservice.domain.chat.respository.ChatMessageRepository; +import com.rljj.chatservice.domain.chat.respository.ChatRoomRepository; +import com.rljj.chatservice.global.util.ConstantUtils; +import com.rljj.switchswitchcommon.jwt.JwtProvider; +import com.rljj.switchswitchentity.chat.ChatRoom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ChatService { + + private final JwtProvider jwtProvider; + private final MessageSender sender; + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + + // 채팅방 만들기 + @Transactional + public void makeChatRoom(Long memberId, ChatRoomRequest requestDto) { + // 1. 유효성 체크 => feignClient + + // 2. 저장 + chatRoomRepository.save(requestDto.toEntity(memberId)); + } + + // 채팅방 리스트 조회 + public ChatRoomListResponse getChatRoomList(Long memberId) { + List chatRoomList = chatRoomRepository.findChatRoomsByMemberId(memberId); + return new ChatRoomListResponse(ChatRoomDto.from(chatRoomList, memberId)); + } + + // 채팅메시지 내역 조회하기 + public ChatMessageListResponse getChatMessageList(Long roomId, Long memberId) { + List chatMessageList = chatMessageRepository.findByChatRoomId(roomId); + return new ChatMessageListResponse(ChatMessageDto.from(chatMessageList, memberId)); + } + + // 채팅메시지 보내기 + public void sendMessage(Message message, String accessToken) { + + // 1. 토큰에서 memberId 추출 + Long memberId = extractMemberIdFromToken(accessToken); + if (memberId == null) { + throw new IllegalArgumentException("Invalid or missing access token"); + } + + // 2. message 객체에 필요한 정보 세팅 + message.setMessageDetails(memberId, LocalDateTime.now()); + + // 3. 메시지 전송 + sender.send(ConstantUtils.KAFKA_TOPIC, message); + + // 4. dynamodb 저장 + chatMessageRepository.saveChatMessage(message.toChatMessage()); + } + + private Long extractMemberIdFromToken(String token) { + if (token != null && token.startsWith("Bearer ")) { + return jwtProvider.parseMemberId(token.substring(7)); + } + return null; + } + +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/MessageReceiver.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/MessageReceiver.java new file mode 100644 index 0000000..c476ad7 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/MessageReceiver.java @@ -0,0 +1,25 @@ +package com.rljj.chatservice.domain.chat.service; + +import com.rljj.chatservice.domain.chat.dto.Message; +import com.rljj.chatservice.global.util.ConstantUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageReceiver { + private final SimpMessageSendingOperations template; + + @KafkaListener(topics = ConstantUtils.KAFKA_TOPIC, autoStartup = "true") + public void receiveMessage(Message message) { + log.info("전송 위치 = /sub/public/"+ message.getChatRoomId()); + log.info("채팅 방으로 메시지 전송 = {}", message); + + // 메시지객체 내부의 채팅방번호를 참조하여, 해당 채팅방 구독자에게 메시지를 발송한다. + template.convertAndSend("/sub/public/" + message.getChatRoomId(), message); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/MessageSender.java b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/MessageSender.java new file mode 100644 index 0000000..7db7d56 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/domain/chat/service/MessageSender.java @@ -0,0 +1,21 @@ +package com.rljj.chatservice.domain.chat.service; + +import com.rljj.chatservice.domain.chat.dto.Message; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageSender { + private final KafkaTemplate kafkaTemplate; + + // 메시지를 지정한 Kafka 토픽으로 전송 + public void send(String topic, Message data) { + + // KafkaTemplate을 사용하여 메시지를 지정된 토픽으로 전송 + kafkaTemplate.send(topic, data); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/config/jwt/JwtConfig.java b/chat-service/src/main/java/com/rljj/chatservice/global/config/jwt/JwtConfig.java new file mode 100644 index 0000000..a424fdc --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/config/jwt/JwtConfig.java @@ -0,0 +1,33 @@ +package com.rljj.chatservice.global.config.jwt; + +import com.rljj.switchswitchcommon.jwt.JwtProvider; +import com.rljj.switchswitchcommon.jwt.JwtProviderImpl; +import com.rljj.switchswitchcommon.jwt.JwtRedisService; +import com.rljj.switchswitchcommon.jwt.JwtRedisServiceImpl; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class JwtConfig { + + @Value("${jwt.expired.access-token}") + private long accessTokenExpireTime; + + @Value("${jwt.expired.refresh-token}") + private long refreshTokenExpireTime; + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtProvider jwtProvider() { + return new JwtProviderImpl(accessTokenExpireTime, refreshTokenExpireTime, jwtSecret); + } + + @Bean + public JwtRedisService jwtRedisService(StringRedisTemplate redisTemplate, JwtProvider jwtProvider) { + return new JwtRedisServiceImpl(redisTemplate, jwtProvider); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/config/redis/RedisConfig.java b/chat-service/src/main/java/com/rljj/chatservice/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..78e05a2 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/config/redis/RedisConfig.java @@ -0,0 +1,31 @@ +package com.rljj.chatservice.global.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port)); + } + + @Bean + public StringRedisTemplate redisTemplate() { + StringRedisTemplate redisTemplate = new StringRedisTemplate(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/config/security/SecurityConfig.java b/chat-service/src/main/java/com/rljj/chatservice/global/config/security/SecurityConfig.java new file mode 100644 index 0000000..a4e53fa --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/config/security/SecurityConfig.java @@ -0,0 +1,43 @@ +package com.rljj.chatservice.global.config.security; + +import com.rljj.switchswitchcommon.jwt.JwtAuthenticationFilter; +import com.rljj.switchswitchcommon.jwt.JwtProvider; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtProvider jwtProvider; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, HttpSession httpSession) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/chat/**").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + .exceptionHandling( + exception -> exception.accessDeniedPage("/login?error=403") + ) + ; + return http.build(); + } + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtProvider); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/config/websocket/StompHandler.java b/chat-service/src/main/java/com/rljj/chatservice/global/config/websocket/StompHandler.java new file mode 100644 index 0000000..dce5a94 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/config/websocket/StompHandler.java @@ -0,0 +1,71 @@ +package com.rljj.chatservice.global.config.websocket; + +import com.rljj.switchswitchcommon.jwt.JwtProvider; +import com.rljj.switchswitchcommon.jwt.JwtRedisService; +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +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.security.access.AccessDeniedException; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Order(Ordered.HIGHEST_PRECEDENCE + 99) // 우선 순위를 높게 설정해서 Security filter들 보다 앞서 실행되게 해준다. +@Component +@RequiredArgsConstructor +@Slf4j +public class StompHandler implements ChannelInterceptor { + private final JwtProvider jwtProvider; + private final JwtRedisService jwtRedisService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (accessor.getCommand() == StompCommand.CONNECT) { + String token = extractToken(accessor); + + // 토큰이 없거나 차단된 경우 접근 거부 + if (isTokenInvalidOrBlocked(token)) { + throw new AccessDeniedException("Access denied: Invalid or blocked token."); + } + + // JWT 검증 + authenticateToken(token, accessor); + } + + log.info("StompAccessor = {}", accessor); + return message; + } + + private String extractToken(StompHeaderAccessor accessor) { + List authHeaders = accessor.getNativeHeader("Authorization"); + if (authHeaders != null && !authHeaders.isEmpty()) { + String token = authHeaders.get(0); + return token.startsWith("Bearer ") ? token.substring(7) : token; + } + return null; + } + + private boolean isTokenInvalidOrBlocked(String token) { + return token == null || jwtRedisService.isBlockedAccessToken(token); + } + + private void authenticateToken(String token, StompHeaderAccessor accessor) { + try { + jwtProvider.validateJwt(token); + } catch (ExpiredJwtException e) { + throw new AccessDeniedException("Access denied: Token has expired."); + } catch (Exception e) { + throw new AccessDeniedException("Access denied: Invalid token."); + } + } + +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/config/websocket/WebSocketConfig.java b/chat-service/src/main/java/com/rljj/chatservice/global/config/websocket/WebSocketConfig.java new file mode 100644 index 0000000..6d089b8 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/config/websocket/WebSocketConfig.java @@ -0,0 +1,48 @@ +package com.rljj.chatservice.global.config.websocket; + +import lombok.RequiredArgsConstructor; +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; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +@Configuration +@RequiredArgsConstructor +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompHandler stompHandler; + + // STOMP 엔드포인트를 등록하는 메서드 + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/chat") // STOMP 엔드포인트 설정 + .setAllowedOriginPatterns("*") // 모든 Origin 허용 + .withSockJS(); // SockJS 사용가능 설정 + } + + // 메시지 브로커를 구성하는 메서드 + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); // /sub/{chatNo}로 주제 구독 요청: 메시지 송신 + registry.setApplicationDestinationPrefixes("/pub"); // /pub/message로 메시지 전송 컨트롤러 라우팅 가능: 메시지 수신 + } + + // 클라이언트 인바운드 채널을 구성하는 메서드 + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // stompHandler를 인터셉터로 등록하여 STOMP 메시지 핸들링을 수행 + registration.interceptors(stompHandler); + } + + // STOMP에서 64KB 이상의 데이터 전송을 못하는 문제 해결 + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registry) { + registry.setMessageSizeLimit(160 * 64 * 1024); + registry.setSendTimeLimit(100 * 10000); + registry.setSendBufferSizeLimit(3 * 512 * 1024); + } +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/util/ConstantUtils.java b/chat-service/src/main/java/com/rljj/chatservice/global/util/ConstantUtils.java new file mode 100644 index 0000000..46180e4 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/util/ConstantUtils.java @@ -0,0 +1,5 @@ +package com.rljj.chatservice.global.util; + +public abstract class ConstantUtils { + public static final String KAFKA_TOPIC = "chat"; +} diff --git a/chat-service/src/main/java/com/rljj/chatservice/global/util/SecurityUtils.java b/chat-service/src/main/java/com/rljj/chatservice/global/util/SecurityUtils.java new file mode 100644 index 0000000..b420e98 --- /dev/null +++ b/chat-service/src/main/java/com/rljj/chatservice/global/util/SecurityUtils.java @@ -0,0 +1,31 @@ +package com.rljj.chatservice.global.util; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public abstract class SecurityUtils { + // 인스턴스화 방지를 위한 private 생성자 추가 + private SecurityUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + public static String getUsername() { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (principal instanceof UserDetails) { + return ((UserDetails) principal).getUsername(); + } + return null; + } + + public static Long getMemberId() { + String username = getUsername(); + if (username != null) { + try { + return Long.parseLong(username); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Username is not a valid number: " + username, e); + } + } + throw new IllegalStateException("Username is null"); + } +} diff --git a/chat-service/src/main/resources/application-local.yml b/chat-service/src/main/resources/application-local.yml index d9ba179..a9acd05 100644 --- a/chat-service/src/main/resources/application-local.yml +++ b/chat-service/src/main/resources/application-local.yml @@ -2,6 +2,18 @@ spring: config: import: - configserver:http://localhost:8888 + kafka: + bootstrap-servers: localhost:9092 + consumer: + group-id: chat-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer aws: dynamodb: diff --git a/chat-service/src/main/resources/test.html b/chat-service/src/main/resources/test.html new file mode 100644 index 0000000..d1c3c27 --- /dev/null +++ b/chat-service/src/main/resources/test.html @@ -0,0 +1,51 @@ + + + + + WebSocket Test + + + + + +

WebSocket 테스트

+ + + + + + diff --git a/switchswitch-entity/src/main/java/com/rljj/switchswitchentity/chat/ChatRoom.java b/switchswitch-entity/src/main/java/com/rljj/switchswitchentity/chat/ChatRoom.java index 938a51a..f76e82a 100644 --- a/switchswitch-entity/src/main/java/com/rljj/switchswitchentity/chat/ChatRoom.java +++ b/switchswitch-entity/src/main/java/com/rljj/switchswitchentity/chat/ChatRoom.java @@ -9,11 +9,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.*; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder public class ChatRoom extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @NotNull @@ -21,14 +20,26 @@ public class ChatRoom extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @NotNull - private Member creatorUser; + private Member author; @ManyToOne(fetch = FetchType.LAZY) @NotNull - private Member interestedUser; + private Member requester; @Enumerated(EnumType.STRING) @NotNull private ChatRoomStatus status; + + public static ChatRoom create(ChipPost chipPost, Member author, Member requester) { + return new ChatRoom(chipPost, author, requester); + } + + @Builder + private ChatRoom(ChipPost chipPost, Member author, Member requester) { + this.chipPost = chipPost; + this.author = author; + this.requester = requester; + this.status = ChatRoomStatus.ACTIVE; + } }