From 94b4cd79390405df22ee9c971299a6a8a66379b6 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Wed, 21 Jan 2026 23:40:23 +0900 Subject: [PATCH 01/20] =?UTF-8?q?chore:=20FRONTEND=5FPROD=5FURL,=20BACKEND?= =?UTF-8?q?=5FURL,=20BACKEND=5FPROD=5FURL=20Value=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linkit/global/config/WebSocketConfig.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java b/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java index 37d98516..8d893762 100644 --- a/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java +++ b/src/main/java/org/swyp/linkit/global/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package org.swyp.linkit.global.config; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -17,6 +18,15 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final WebSocketAuthInterceptor webSocketAuthInterceptor; private final StompErrorHandler stompErrorHandler; + @Value("${FRONTEND_URL}") + private String frontendUrl; + @Value("${FRONTEND_PROD_URL}") + private String frontendProdUrl; + @Value("${BACKEND_URL}") + private String backendUrl; + @Value("${BACKEND_PROD_URL}") + private String backendProdUrl; + @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 클라이언트가 구독할 prefix (서버 -> 클라이언트) @@ -34,10 +44,10 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { // WebSocket 연결 엔드포인트 (SockJS fallback 포함) registry.addEndpoint("/ws") .setAllowedOriginPatterns( - "{FRONTEND_URL}", // 로컬 개발 - "{FRONTEND_PROD_URL}", // Vercel 프리뷰/배포 - "{BACKEND_URL}", // 운영 도메인 - "{BACKEND_PROD_URL}" // 서브도메인 + frontendUrl, // 로컬 개발 + frontendProdUrl, // Vercel 프리뷰/배포 + backendUrl, // 운영 도메인 + backendProdUrl // 서브도메인 ) .withSockJS(); From 5efc1b131d9c0a156d365a4f8fb6c19a921ba7f3 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Sat, 4 Apr 2026 22:23:57 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor=20:=20message=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/linkit/domain/notification/dto/NotificationDto.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationDto.java b/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationDto.java index fce64e73..0ab0e311 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationDto.java +++ b/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationDto.java @@ -21,8 +21,9 @@ public class NotificationDto { private Long refId; private boolean isRead; private LocalDateTime createdAt; + private String message; - public static NotificationDto from(Notification notification) { + public static NotificationDto from(Notification notification, String message) { return NotificationDto.builder() .id(notification.getId()) .receiverId(notification.getReceiver().getId()) @@ -31,6 +32,7 @@ public static NotificationDto from(Notification notification) { .refId(notification.getRefId()) .isRead(notification.isRead()) .createdAt(notification.getCreatedAt()) + .message(message) .build(); } } \ No newline at end of file From 3c715565d595ee4e0e66d8f42a4e3f4e686e1040 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Sat, 4 Apr 2026 22:24:08 +0900 Subject: [PATCH 03/20] =?UTF-8?q?refactor=20:=20REST=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20message=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceImpl.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 60d5af67..58c94939 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -25,6 +25,9 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -70,7 +73,7 @@ public NotificationDto createNotification(Long receiverId, Long senderId, Notifi publishNotificationToRedis(savedNotification, senderNickname, message); log.info("알림 생성 및 발송: receiverId={}, type={}, refId={}", receiverId, type, refId); - return NotificationDto.from(savedNotification); + return NotificationDto.from(savedNotification, message); } @Override @@ -86,7 +89,7 @@ public NotificationDto createSystemNotification(Long receiverId, NotificationTyp publishNotificationToRedis(savedNotification, "시스템", message); log.info("시스템 알림 생성 및 발송: receiverId={}, type={}, refId={}", receiverId, type, refId); - return NotificationDto.from(savedNotification); + return NotificationDto.from(savedNotification, message); } // ===== 미읽음 개수 조회 ===== @@ -133,8 +136,22 @@ public NotificationListResponseDto getNotifications(Long userId) { combinedNotifications.addAll(unreadNotifications); combinedNotifications.addAll(readNotifications); + // sender nickname 배치 조회 + Set senderIds = combinedNotifications.stream() + .map(Notification::getSenderId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map nicknameMap = userRepository.findAllById(senderIds).stream() + .collect(Collectors.toMap(User::getId, User::getNickname)); + List notificationDtos = combinedNotifications.stream() - .map(NotificationDto::from) + .map(n -> { + String senderNickname = n.getSenderId() != null + ? nicknameMap.getOrDefault(n.getSenderId(), "시스템") + : "시스템"; + String message = generateNotificationMessage(n.getNotificationType(), senderNickname); + return NotificationDto.from(n, message); + }) .collect(Collectors.toList()); return NotificationListResponseDto.of(notificationDtos, unreadNotifications.size()); From c0c0345ba13375a42a485e1ddaf9d7a89176b910 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Sat, 4 Apr 2026 22:24:18 +0900 Subject: [PATCH 04/20] =?UTF-8?q?refactor=20:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatStompController.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java b/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java index 020d3393..45e1e48c 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java +++ b/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java @@ -8,7 +8,10 @@ import org.springframework.stereotype.Controller; import org.swyp.linkit.domain.chat.dto.request.ChatSendRequestDto; import org.swyp.linkit.domain.chat.entity.ChatMessage; +import org.swyp.linkit.domain.chat.entity.ChatRoom; import org.swyp.linkit.domain.chat.service.ChatService; +import org.swyp.linkit.domain.notification.entity.NotificationType; +import org.swyp.linkit.domain.notification.service.NotificationService; import java.security.Principal; @@ -24,6 +27,7 @@ public class ChatStompController { private final ChatService chatService; + private final NotificationService notificationService; /** * 메시지 전송 @@ -45,6 +49,11 @@ public void send(@Payload ChatSendRequestDto dto, Principal principal) { dto.getMessageType(), dto.getImageUrl()); chatService.publishToRedis(saved); + + // 상대방에게 채팅 알림 발송 + ChatRoom room = saved.getChatRoom(); + Long receiverId = room.getMentorId().equals(senderId) ? room.getMenteeId() : room.getMentorId(); + notificationService.createNotification(receiverId, senderId, NotificationType.CHAT_MESSAGE, roomId); } /** From 761d298efae22eeb7ef5add7af86c3a985597c25 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Sat, 4 Apr 2026 23:50:03 +0900 Subject: [PATCH 05/20] =?UTF-8?q?refactor=20:=20null=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceImpl.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 58c94939..d768c488 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -24,6 +24,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -141,14 +142,15 @@ public NotificationListResponseDto getNotifications(Long userId) { .map(Notification::getSenderId) .filter(Objects::nonNull) .collect(Collectors.toSet()); - Map nicknameMap = userRepository.findAllById(senderIds).stream() - .collect(Collectors.toMap(User::getId, User::getNickname)); + Map nicknameMap = senderIds.isEmpty() + ? Collections.emptyMap() + : userRepository.findAllById(senderIds).stream() + .collect(Collectors.toMap(User::getId, + u -> u.getNickname() != null ? u.getNickname() : "시스템")); List notificationDtos = combinedNotifications.stream() .map(n -> { - String senderNickname = n.getSenderId() != null - ? nicknameMap.getOrDefault(n.getSenderId(), "시스템") - : "시스템"; + String senderNickname = nicknameMap.getOrDefault(n.getSenderId(), "시스템"); String message = generateNotificationMessage(n.getNotificationType(), senderNickname); return NotificationDto.from(n, message); }) From a310cb429c916e6f0c5c3ebf1453062323d765d8 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Sun, 5 Apr 2026 00:07:40 +0900 Subject: [PATCH 06/20] =?UTF-8?q?refactor=20:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chat/controller/ChatStompController.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java b/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java index 45e1e48c..020d3393 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java +++ b/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java @@ -8,10 +8,7 @@ import org.springframework.stereotype.Controller; import org.swyp.linkit.domain.chat.dto.request.ChatSendRequestDto; import org.swyp.linkit.domain.chat.entity.ChatMessage; -import org.swyp.linkit.domain.chat.entity.ChatRoom; import org.swyp.linkit.domain.chat.service.ChatService; -import org.swyp.linkit.domain.notification.entity.NotificationType; -import org.swyp.linkit.domain.notification.service.NotificationService; import java.security.Principal; @@ -27,7 +24,6 @@ public class ChatStompController { private final ChatService chatService; - private final NotificationService notificationService; /** * 메시지 전송 @@ -49,11 +45,6 @@ public void send(@Payload ChatSendRequestDto dto, Principal principal) { dto.getMessageType(), dto.getImageUrl()); chatService.publishToRedis(saved); - - // 상대방에게 채팅 알림 발송 - ChatRoom room = saved.getChatRoom(); - Long receiverId = room.getMentorId().equals(senderId) ? room.getMenteeId() : room.getMentorId(); - notificationService.createNotification(receiverId, senderId, NotificationType.CHAT_MESSAGE, roomId); } /** From 720fefff123a5daf5c60b0fe575804451d6fdbf8 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Mon, 6 Apr 2026 21:43:44 +0900 Subject: [PATCH 07/20] =?UTF-8?q?fix=20:=20=20RedisConfig=EC=97=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/swyp/linkit/global/config/RedisConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/swyp/linkit/global/config/RedisConfig.java b/src/main/java/org/swyp/linkit/global/config/RedisConfig.java index 04c3acbd..9f8e3f09 100644 --- a/src/main/java/org/swyp/linkit/global/config/RedisConfig.java +++ b/src/main/java/org/swyp/linkit/global/config/RedisConfig.java @@ -8,12 +8,14 @@ import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.swyp.linkit.domain.chat.redis.RedisChatSubscriber; +import org.swyp.linkit.domain.notification.redis.RedisNotificationSubscriber; @Profile("!test") @Configuration public class RedisConfig { private static final String CHAT_CHANNEL_PATTERN = "chat:room:*"; + private static final String NOTIFICATION_CHANNEL_PATTERN = "notification:user:*"; @Bean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { @@ -23,11 +25,13 @@ public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connection @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, - RedisChatSubscriber redisChatSubscriber) { + RedisChatSubscriber redisChatSubscriber, + RedisNotificationSubscriber redisNotificationSubscriber) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(redisChatSubscriber, new PatternTopic(CHAT_CHANNEL_PATTERN)); + container.addMessageListener(redisNotificationSubscriber, new PatternTopic(NOTIFICATION_CHANNEL_PATTERN)); return container; } } \ No newline at end of file From a3642c410bde8063b5834757d3ea502c1bbc14b1 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Mon, 6 Apr 2026 21:56:56 +0900 Subject: [PATCH 08/20] =?UTF-8?q?fix=20:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EC=8B=9C=20?= =?UTF-8?q?ChatMessage.content=20NPE=20=EB=B0=9C=EC=83=9D=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linkit/domain/chat/service/ChatRoomService.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/chat/service/ChatRoomService.java b/src/main/java/org/swyp/linkit/domain/chat/service/ChatRoomService.java index 1ba82847..0f5fcb49 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/service/ChatRoomService.java +++ b/src/main/java/org/swyp/linkit/domain/chat/service/ChatRoomService.java @@ -9,6 +9,7 @@ import org.swyp.linkit.domain.chat.entity.ChatRoom; import org.swyp.linkit.domain.chat.entity.ChatRoomDelete; import org.swyp.linkit.domain.chat.entity.ChatRoomStatus; +import org.swyp.linkit.domain.chat.entity.MessageType; import org.swyp.linkit.domain.chat.repository.ChatMessageRepository; import org.swyp.linkit.domain.chat.repository.ChatRoomDeleteRepository; import org.swyp.linkit.domain.chat.repository.ChatRoomRepository; @@ -86,7 +87,7 @@ public List findRoomsByUserId(Long userId) { ChatMessage lastMessage = (ChatMessage) row[1]; // 마지막 메시지 내용 - String lastMessageContent = lastMessage != null ? lastMessage.getContent() : null; + String lastMessageContent = lastMessage != null ? resolveLastMessageContent(lastMessage) : null; // 상대방 정보 (연관관계로 바로 접근) boolean isMentor = room.getMentorId().equals(userId); @@ -120,7 +121,7 @@ public ChatRoomDto findDtoById(Long roomId, Long currentUserId) { String lastMessageContent = null; if (room.getLastMessageId() != null) { lastMessageContent = chatMessageRepository.findById(room.getLastMessageId()) - .map(ChatMessage::getContent) + .map(this::resolveLastMessageContent) .orElse(null); } @@ -180,6 +181,13 @@ public boolean isMentor(ChatRoom room, Long userId) { // === Private Helper Methods === + private String resolveLastMessageContent(ChatMessage message) { + if (message.getMessageType() == MessageType.IMAGE) { + return "[이미지]"; + } + return message.getContent(); + } + private User findUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("User not found: " + userId)); From 8c31e08330737865b7801bb124ebf28a2ec3c83c Mon Sep 17 00:00:00 2001 From: rorrxr Date: Mon, 6 Apr 2026 22:25:15 +0900 Subject: [PATCH 09/20] =?UTF-8?q?refactor=20:=20=EB=8B=89=EB=84=A4?= =?UTF-8?q?=EC=9E=84=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=8B=9C=EC=8A=A4=ED=85=9C/=EC=95=8C?= =?UTF-8?q?=20=EC=88=98=20=EC=97=86=EC=9D=8C=20=EA=B5=AC=EB=B6=84=20(resol?= =?UTF-8?q?veNickname()=EC=9C=BC=EB=A1=9C=20null/blank=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EC=B2=98=EB=A6=AC,=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=EA=B3=BC=20=ED=83=88=ED=87=B4/=EB=AF=B8?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=9C=A0=EC=A0=80=20=EA=B5=AC=EB=B6=84,?= =?UTF-8?q?=20toMap=20merge=20function=20=EC=B6=94=EA=B0=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=82=A4=20=EC=98=88=EC=99=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceImpl.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 1a18694a..7963a0ff 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -69,7 +69,7 @@ public NotificationDto createNotification(Long receiverId, Long senderId, Notifi Notification savedNotification = notificationRepository.save(notification); // WebSocket 실시간 알림 발송 - String senderNickname = sender != null ? sender.getNickname() : "시스템"; + String senderNickname = resolveNickname(sender); String message = generateNotificationMessage(type, senderNickname); publishNotificationToRedis(savedNotification, senderNickname, message); @@ -154,8 +154,10 @@ public NotificationListResponseDto getNotifications(Long userId) { Map nicknameMap = senderIds.isEmpty() ? Collections.emptyMap() : userRepository.findAllById(senderIds).stream() - .collect(Collectors.toMap(User::getId, - u -> u.getNickname() != null ? u.getNickname() : "시스템")); + .collect(Collectors.toMap( + User::getId, + u -> hasValidNickname(u) ? u.getNickname() : "알 수 없음", + (existing, replacement) -> existing)); List notificationDtos = combinedNotifications.stream() .map(n -> { @@ -240,6 +242,18 @@ public int markAllAsRead(Long userId) { // ===== Private Methods ===== + /** + * sender가 null이면 "시스템", 존재하지만 닉네임이 없으면 "알 수 없음" + */ + private String resolveNickname(User sender) { + if (sender == null) return "시스템"; + return hasValidNickname(sender) ? sender.getNickname() : "알 수 없음"; + } + + private boolean hasValidNickname(User user) { + return user.getNickname() != null && !user.getNickname().isBlank(); + } + private User findUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); From a0541722093e6f7ab9afc88fab02a93089c6778b Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 7 Apr 2026 23:30:38 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix=20:=20Redis=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=80=20JSON=20=EC=97=AD=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=ED=99=94=20=EC=98=A4=EB=A5=98=EB=A1=9C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AF=B8=EC=88=98=EC=8B=A0=20=EC=9D=B4=EC=8A=88=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linkit/domain/notification/dto/NotificationMessageDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java b/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java index 1a134104..55254614 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java +++ b/src/main/java/org/swyp/linkit/domain/notification/dto/NotificationMessageDto.java @@ -15,7 +15,7 @@ * WebSocket으로 전송되는 알림 메시지 DTO */ @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder(access = AccessLevel.PRIVATE) public class NotificationMessageDto { From ba2771a81bde86369bfc08e5234cbecbf79deeef Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 7 Apr 2026 23:31:47 +0900 Subject: [PATCH 11/20] =?UTF-8?q?fix=20:=20=EC=9D=98=EB=AF=B8=EC=97=86?= =?UTF-8?q?=EB=8A=94=20ObjectMapper=EB=A5=BC=20RequiredArgsConstructor?= =?UTF-8?q?=EB=A1=9C=20Spring=20Bean=20=EC=A3=BC=EC=9E=85=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java | 2 +- .../java/org/swyp/linkit/domain/chat/service/ChatService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java b/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java index c9467189..2721edfa 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java +++ b/src/main/java/org/swyp/linkit/domain/chat/redis/RedisChatSubscriber.java @@ -15,7 +15,7 @@ public class RedisChatSubscriber implements MessageListener { private final SimpMessagingTemplate messagingTemplate; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override public void onMessage(Message message, byte[] pattern) { diff --git a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java index 005fa918..8fe4506f 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java +++ b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java @@ -37,7 +37,7 @@ public class ChatService { private final UserRepository userRepository; private final StringRedisTemplate redisTemplate; private final NotificationService notificationService; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; private static final String CHAT_CHANNEL_PREFIX = "chat:room:"; From de0c2600aa0fadec50b82e5044c1aac838059a41 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 7 Apr 2026 23:32:25 +0900 Subject: [PATCH 12/20] =?UTF-8?q?fix=20:=20Redis=20publish=20=EC=8B=9C=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=81=EC=9A=A9=20(=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=B4=20=EB=A1=A4=EB=B0=B1=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=EC=97=90=EB=8F=84=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=ED=95=98=EB=8A=94=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationServiceImpl.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 7963a0ff..50a93f1a 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -7,6 +7,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.swyp.linkit.domain.notification.dto.NotificationDto; import org.swyp.linkit.domain.notification.dto.NotificationMessageDto; import org.swyp.linkit.domain.notification.dto.response.ChatRoomUnreadCountResponseDto; @@ -260,18 +262,31 @@ private User findUserById(Long userId) { } /** - * Redis Pub/Sub을 통해 알림 발행 + * Redis Pub/Sub을 통해 알림 발행 (트랜잭션 커밋 이후 발행) */ private void publishNotificationToRedis(Notification notification, String senderNickname, String message) { NotificationMessageDto payload = NotificationMessageDto.from(notification, senderNickname, message); + String channel = NOTIFICATION_CHANNEL_PREFIX + notification.getReceiver().getId(); + + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doPublish(channel, payload, notification.getId()); + } + }); + } else { + doPublish(channel, payload, notification.getId()); + } + } + private void doPublish(String channel, NotificationMessageDto payload, Long notificationId) { try { String json = objectMapper.writeValueAsString(payload); - String channel = NOTIFICATION_CHANNEL_PREFIX + notification.getReceiver().getId(); redisTemplate.convertAndSend(channel, json); - log.info("Redis 알림 발행: channel={}, notificationId={}", channel, notification.getId()); + log.info("Redis 알림 발행: channel={}, notificationId={}", channel, notificationId); } catch (JsonProcessingException e) { - log.error("알림 직렬화 실패", e); + log.error("알림 직렬화 실패: notificationId={}", notificationId, e); } } @@ -283,7 +298,7 @@ private String generateNotificationMessage(NotificationType type, String senderN case REQUEST_RECEIVED -> senderNickname + "님이 스킬 교환을 요청했습니다."; case REQUEST_SENT -> senderNickname + "님에게 스킬 교환 요청을 보냈습니다."; case REQUEST_STATUS_CHANGED -> "스킬 교환 요청 상태가 변경되었습니다."; - case CHAT_MESSAGE -> senderNickname + "님에게 메시지 요청이 왔습니다."; + case CHAT_MESSAGE -> senderNickname + "님이 메시지를 보냈습니다."; }; } } \ No newline at end of file From e0373903cdc188a0120c9777885817d4685c7325 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 7 Apr 2026 23:42:32 +0900 Subject: [PATCH 13/20] =?UTF-8?q?refactor=20:=20ErrorCode=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(N005,=20CH007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/swyp/linkit/global/error/ErrorCode.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/swyp/linkit/global/error/ErrorCode.java b/src/main/java/org/swyp/linkit/global/error/ErrorCode.java index 5466542c..1bc6027d 100644 --- a/src/main/java/org/swyp/linkit/global/error/ErrorCode.java +++ b/src/main/java/org/swyp/linkit/global/error/ErrorCode.java @@ -135,6 +135,9 @@ public enum ErrorCode implements BaseErrorCode { @ExplainError("멘토와 멘티가 동일한 사용자인 경우 발생합니다.") CHAT_SAME_USER(HttpStatus.BAD_REQUEST, "CH006", "멘토와 멘티는 서로 다른 사용자여야 합니다."), + @ExplainError("Redis Pub/Sub을 통한 채팅 메시지 발행에 실패한 경우 발생합니다.") + CHAT_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CH007", "채팅 메시지 발행에 실패했습니다."), + // 크레딧 @ExplainError("사용자의 크레딧 정보가 존재하지 않는 경우 발생합니다.") NOT_FOUND_CREDIT(HttpStatus.NOT_FOUND, "CR001", "크레딧 정보를 찾을 수 없습니다."), @@ -216,6 +219,9 @@ public enum ErrorCode implements BaseErrorCode { @ExplainError("지원하지 않는 알림 타입을 요청한 경우 발생합니다.") INVALID_NOTIFICATION_TYPE(HttpStatus.BAD_REQUEST, "N004", "유효하지 않은 알림 타입입니다."), + @ExplainError("Redis Pub/Sub을 통한 알림 발행에 실패한 경우 발생합니다.") + NOTIFICATION_PUBLISH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "N005", "알림 발행에 실패했습니다."), + // 채팅 파일 업로드 @ExplainError("채팅 파일 크기가 10MB를 초과하는 경우 발생합니다.") CHAT_FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "CF001", "파일 용량은 최대 10MB까지 업로드할 수 있습니다."), From ffe4c6ac01a3968c97b1085891f33fcd77b6c46d Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 7 Apr 2026 23:44:20 +0900 Subject: [PATCH 14/20] =?UTF-8?q?feat=20:=20=EC=83=88=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20(N005=20:?= =?UTF-8?q?=20NotificationPublishFailedException,=20CH007=20:=20ChatPublis?= =?UTF-8?q?hFailedException)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/linkit/domain/chat/service/ChatService.java | 11 +++++++---- .../notification/service/NotificationServiceImpl.java | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java index 8fe4506f..4f1672b6 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java +++ b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java @@ -189,8 +189,9 @@ public void deleteMessages(Long roomId, Long userId, List messageIds) { * Redis Pub/Sub을 통해 메시지 발행 */ public void publishToRedis(ChatMessage message) { + Long roomId = message.getChatRoom().getId(); ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder() - .roomId(message.getChatRoom().getId()) + .roomId(roomId) .messageId(message.getId()) .senderId(message.getSenderId()) .senderRole(message.getSenderRole().name()) @@ -203,11 +204,12 @@ public void publishToRedis(ChatMessage message) { try { String json = objectMapper.writeValueAsString(payload); - String channel = CHAT_CHANNEL_PREFIX + message.getChatRoom().getId(); + String channel = CHAT_CHANNEL_PREFIX + roomId; redisTemplate.convertAndSend(channel, json); log.info("Redis 메시지 발행: channel={}, messageId={}", channel, message.getId()); } catch (JsonProcessingException e) { - log.error("ChatPayload 직렬화 실패", e); + log.error("채팅 메시지 직렬화 실패: roomId={}, messageId={}", roomId, message.getId(), e); + throw new ChatPublishFailedException(roomId); } } @@ -228,7 +230,8 @@ public void publishReadEvent(Long roomId, Long userId, Long lastReadMessageId) { redisTemplate.convertAndSend(channel, json); log.info("읽음 이벤트 발행: channel={}, readerId={}", channel, userId); } catch (JsonProcessingException e) { - log.error("읽음 이벤트 직렬화 실패", e); + log.error("읽음 이벤트 직렬화 실패: roomId={}, userId={}", roomId, userId, e); + throw new ChatPublishFailedException(roomId); } } diff --git a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java index 50a93f1a..068d8a95 100644 --- a/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/notification/service/NotificationServiceImpl.java @@ -22,6 +22,7 @@ import org.swyp.linkit.global.error.exception.NotificationAccessDeniedException; import org.swyp.linkit.global.error.exception.NotificationAlreadyReadException; import org.swyp.linkit.global.error.exception.NotificationNotFoundException; +import org.swyp.linkit.global.error.exception.NotificationPublishFailedException; import org.swyp.linkit.global.error.exception.UserNotFoundException; import java.time.LocalDateTime; @@ -286,7 +287,9 @@ private void doPublish(String channel, NotificationMessageDto payload, Long noti redisTemplate.convertAndSend(channel, json); log.info("Redis 알림 발행: channel={}, notificationId={}", channel, notificationId); } catch (JsonProcessingException e) { - log.error("알림 직렬화 실패: notificationId={}", notificationId, e); + // afterCommit() 내부에서는 예외를 throw해도 Spring이 억제하므로 로그로 대체 + NotificationPublishFailedException ex = new NotificationPublishFailedException(notificationId); + log.error("[{}] {}", ex.getErrorCode().getCode(), ex.getMessage(), e); } } From daf7fb9ba7fd72ec9570231c9e7ea1c3e0fd92c2 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 7 Apr 2026 23:44:47 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor=20:=20=EC=8B=A0=EA=B7=9C=20excep?= =?UTF-8?q?tion=20Swagger=20Docs=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/linkit/global/swagger/docs/ChatExceptionDocs.java | 7 +++++++ .../global/swagger/docs/NotificationExceptionDocs.java | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java b/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java index 75f175e8..ee577d1b 100644 --- a/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java +++ b/src/main/java/org/swyp/linkit/global/swagger/docs/ChatExceptionDocs.java @@ -59,4 +59,11 @@ public BaseErrorCode getErrorCode() { return ErrorCode.CHAT_SAME_USER; } } + + public static class ChatPublishFailedException implements SwaggerExampleExceptions { + @Override + public BaseErrorCode getErrorCode() { + return ErrorCode.CHAT_PUBLISH_FAILED; + } + } } diff --git a/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java b/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java index 397782b7..63db9eb2 100644 --- a/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java +++ b/src/main/java/org/swyp/linkit/global/swagger/docs/NotificationExceptionDocs.java @@ -45,4 +45,11 @@ public BaseErrorCode getErrorCode() { return ErrorCode.INVALID_NOTIFICATION_TYPE; } } + + public static class NotificationPublishFailedException implements SwaggerExampleExceptions { + @Override + public BaseErrorCode getErrorCode() { + return ErrorCode.NOTIFICATION_PUBLISH_FAILED; + } + } } From c36084baa4c75a503a941e11de65caaf40cd9784 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Thu, 9 Apr 2026 00:37:59 +0900 Subject: [PATCH 16/20] =?UTF-8?q?fix=20:=20=EB=AF=B8=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EB=90=9C=20=EC=98=88=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(NotificationPublishF?= =?UTF-8?q?ailedException,=20ChatPublishFailedException)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ChatPublishFailedException.java | 16 ++++++++++++++++ .../NotificationPublishFailedException.java | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java create mode 100644 src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java diff --git a/src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java b/src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java new file mode 100644 index 00000000..c5a8e993 --- /dev/null +++ b/src/main/java/org/swyp/linkit/global/error/exception/ChatPublishFailedException.java @@ -0,0 +1,16 @@ +package org.swyp.linkit.global.error.exception; + +import org.swyp.linkit.global.error.ErrorCode; +import org.swyp.linkit.global.error.exception.base.BusinessException; + +public class ChatPublishFailedException extends BusinessException { + + public ChatPublishFailedException() { + super(ErrorCode.CHAT_PUBLISH_FAILED); + } + + public ChatPublishFailedException(Long roomId) { + super(ErrorCode.CHAT_PUBLISH_FAILED, + "채팅 메시지 발행에 실패했습니다. roomId=" + roomId); + } +} diff --git a/src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java b/src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java new file mode 100644 index 00000000..49df93b1 --- /dev/null +++ b/src/main/java/org/swyp/linkit/global/error/exception/NotificationPublishFailedException.java @@ -0,0 +1,16 @@ +package org.swyp.linkit.global.error.exception; + +import org.swyp.linkit.global.error.ErrorCode; +import org.swyp.linkit.global.error.exception.base.BusinessException; + +public class NotificationPublishFailedException extends BusinessException { + + public NotificationPublishFailedException() { + super(ErrorCode.NOTIFICATION_PUBLISH_FAILED); + } + + public NotificationPublishFailedException(Long notificationId) { + super(ErrorCode.NOTIFICATION_PUBLISH_FAILED, + "알림 발행에 실패했습니다. notificationId=" + notificationId); + } +} From 0cc0de4081b72eefa9942fa9aeb85917d8bdb431 Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 14 Apr 2026 06:27:34 +0900 Subject: [PATCH 17/20] =?UTF-8?q?feat=20:=20=EC=8A=A4=ED=82=AC=EA=B5=90?= =?UTF-8?q?=ED=99=98=20=EC=9A=94=EC=B2=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20Notification=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=97=90?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청 생성·수락·거절·취소·만료 시 Notification 레코드를 생성하여 알림 센터 노출, /notifications/unread-count 정상화, WebSocket 실시간 알림 푸시를 지원한다. 기존 SkillExchange isRequesterRead/isReceiverRead 플래그 로직은 유지하며, getSentRequests/getReceivedRequests 조회 시 Notification 읽음 처리도 병행한다. Co-Authored-By: Claude Sonnet 4.6 --- .../service/SkillExchangeExpireProcessor.java | 11 ++++++ .../SkillExchangeRequestProcessor.java | 11 ++++++ .../service/SkillExchangeServiceImpl.java | 35 +++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java index d3f63e40..af5d002e 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeExpireProcessor.java @@ -9,6 +9,8 @@ import org.swyp.linkit.domain.credit.service.CreditService; import org.swyp.linkit.domain.exchange.entity.SkillExchange; import org.swyp.linkit.domain.exchange.repository.SkillExchangeRepository; +import org.swyp.linkit.domain.notification.entity.NotificationType; +import org.swyp.linkit.domain.notification.service.NotificationService; import org.swyp.linkit.global.error.exception.ExchangeNotFoundException; @Component @@ -18,6 +20,7 @@ public class SkillExchangeExpireProcessor { private final SkillExchangeRepository skillExchangeRepository; private final CreditService creditService; + private final NotificationService notificationService; // 새로운 트랜잭션 적용 @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -36,6 +39,14 @@ public void expireSingleSkillExchange(Long skillExchangeId){ // 크레딧 환불 처리 creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_EXPIRED); + + // 알림 생성 (requester, receiver 모두에게 시스템 알림 — afterCommit 시 Redis 발행) + notificationService.createSystemNotification( + skillExchange.getRequester().getId(), + NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); + notificationService.createSystemNotification( + skillExchange.getReceiver().getId(), + NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); log.debug("거래 만료 처리 완료. skillExchangeId: {}", skillExchange.getId()); } } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeRequestProcessor.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeRequestProcessor.java index 83a54c2a..dae4059e 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeRequestProcessor.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeRequestProcessor.java @@ -9,6 +9,8 @@ import org.swyp.linkit.domain.exchange.entity.ExchangeStatus; import org.swyp.linkit.domain.exchange.entity.SkillExchange; import org.swyp.linkit.domain.exchange.repository.SkillExchangeRepository; +import org.swyp.linkit.domain.notification.entity.NotificationType; +import org.swyp.linkit.domain.notification.service.NotificationService; import org.swyp.linkit.domain.user.entity.User; import org.swyp.linkit.domain.user.entity.UserSkill; import org.swyp.linkit.domain.user.service.UserService; @@ -32,6 +34,7 @@ public class SkillExchangeRequestProcessor { private final CreditService creditService; private final UserService userService; private final UserSkillService userSkillService; + private final NotificationService notificationService; /** * 처리 순서: @@ -78,6 +81,14 @@ public SkillExchangeResponseDto executeWithLock(Long requesterId, // 6. 크레딧 차감 및 사용 내역 생성 creditService.useCreditForExchangeRequest(saved); + // 7. 알림 생성 (멘토: REQUEST_RECEIVED, 멘티: REQUEST_SENT) — afterCommit 시 Redis 발행 + notificationService.createNotification( + saved.getReceiver().getId(), saved.getRequester().getId(), + NotificationType.REQUEST_RECEIVED, saved.getId()); + notificationService.createNotification( + saved.getRequester().getId(), saved.getReceiver().getId(), + NotificationType.REQUEST_SENT, saved.getId()); + // TX 커밋 → 락 해제 return SkillExchangeResponseDto.from(saved); } diff --git a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java index 5046fd58..68100ec1 100644 --- a/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java +++ b/src/main/java/org/swyp/linkit/domain/exchange/service/SkillExchangeServiceImpl.java @@ -11,6 +11,8 @@ import org.swyp.linkit.domain.credit.service.CreditService; import org.swyp.linkit.domain.exchange.dto.SkillExchangeDto; import org.swyp.linkit.domain.exchange.dto.response.*; +import org.swyp.linkit.domain.notification.entity.NotificationType; +import org.swyp.linkit.domain.notification.service.NotificationService; import org.swyp.linkit.domain.exchange.dto.response.ReceivedExchangeDetailsResponseDto; import org.swyp.linkit.domain.exchange.dto.response.SentExchangeDetailsResponseDto; import org.swyp.linkit.domain.exchange.entity.ExchangeStatus; @@ -50,6 +52,7 @@ public class SkillExchangeServiceImpl implements SkillExchangeService { private final SkillExchangeExpireProcessor exchangeExpireProcessor; private final SkillExchangeRequestProcessor exchangeRequestProcessor; private final SkillExchangePreValidator exchangePreValidator; + private final NotificationService notificationService; /** * 멘토의 거래 가능 스킬 목록 조회 @@ -228,6 +231,10 @@ public SentExchangeDetailsResponseDto getSentRequests(Long userId, Long cursorId // 4. bulkUpdate (isRequesterRead = false -> true) exchangeRepository.bulkUpdateRequesterReadStatus(userId); + + // 5. Notification 도메인 읽음 처리 (REQUEST_SENT + REQUEST_STATUS_CHANGED) + notificationService.markSentRequestAsRead(userId); + return responseDto; } @@ -247,8 +254,12 @@ public ReceivedExchangeDetailsResponseDto getReceivedRequests(Long userId, Long // 3. 응답 Dto 변환 ReceivedExchangeDetailsResponseDto responseDto = ReceivedExchangeDetailsResponseDto.from(slice); - // 4.bulkUpdate (isReceiverRead = false -> true) + // 4. bulkUpdate (isReceiverRead = false -> true) exchangeRepository.bulkUpdateReceiverReadStatus(userId); + + // 5. Notification 도메인 읽음 처리 (REQUEST_RECEIVED) + notificationService.markReceivedRequestAsRead(userId); + return responseDto; } @@ -271,7 +282,12 @@ public SkillExchangeResponseDto acceptSkillExchange(Long receiverId, Long skillE // 4. requester 에게 변경 사항 표시 skillExchange.updateRequesterReadToFalse(); - // 5. Settlement 생성 + // 5. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + notificationService.createNotification( + skillExchange.getRequester().getId(), receiverId, + NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId); + + // 6. Settlement 생성 settlementService.createSettlement(skillExchange); // 5. 응답 Dto 변환 @@ -297,7 +313,12 @@ public SkillExchangeResponseDto rejectSkillExchange(Long receiverId, Long skillE // 4. requester 에게 변경 사항 표시 skillExchange.updateRequesterReadToFalse(); - // 5. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException + // 5. 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + notificationService.createNotification( + skillExchange.getRequester().getId(), receiverId, + NotificationType.REQUEST_STATUS_CHANGED, skillExchangeId); + + // 6. requester 크레딧 환불 -> NotFoundCreditException, InvalidCreditAmountException creditService.refundCreditForExchange(skillExchange, HistoryType.EXCHANGE_REJECTED); // 5. 응답 Dto 변환 @@ -407,6 +428,10 @@ private void processParticipantCancel(Long userId, SkillExchange skillExchange) settlementService.cancelSettlement(skillExchange.getId()); } skillExchange.updateReceiverReadToFalse(); + // 알림 생성 (receiver에게 REQUEST_STATUS_CHANGED) + notificationService.createNotification( + skillExchange.getReceiver().getId(), userId, + NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); } else{ // receiver -> ACCEPTED일 때만 취소 가능 if (currentStatus != ExchangeStatus.ACCEPTED) { @@ -414,6 +439,10 @@ private void processParticipantCancel(Long userId, SkillExchange skillExchange) } settlementService.cancelSettlement(skillExchange.getId()); skillExchange.updateRequesterReadToFalse(); + // 알림 생성 (requester에게 REQUEST_STATUS_CHANGED) + notificationService.createNotification( + skillExchange.getRequester().getId(), userId, + NotificationType.REQUEST_STATUS_CHANGED, skillExchange.getId()); } } From fddb8da6b553f6230353d791481606916bf23f7b Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 14 Apr 2026 06:34:29 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactor=20:=20=EC=B1=84=ED=8C=85=20Redis?= =?UTF-8?q?=20=EB=B0=9C=ED=96=89=EC=9D=84=20Transactional=20Outbox=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=EC=9C=BC=EB=A1=9C=20=EB=82=B4=EC=9E=AC?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveMessage/markAsRead 내부에서 afterCommit 콜백으로 Redis 발행을 처리하여 Controller에서 publishToRedis/publishReadEvent를 직접 호출하지 않도록 개선. publish 메서드를 private으로 변경하고 afterCommit 내 예외는 로그로 처리. Co-Authored-By: Claude Sonnet 4.6 --- .../chat/controller/ChatStompController.java | 8 +-- .../domain/chat/service/ChatService.java | 70 ++++++++++++------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java b/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java index 020d3393..0e64e6f0 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java +++ b/src/main/java/org/swyp/linkit/domain/chat/controller/ChatStompController.java @@ -7,7 +7,6 @@ import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Controller; import org.swyp.linkit.domain.chat.dto.request.ChatSendRequestDto; -import org.swyp.linkit.domain.chat.entity.ChatMessage; import org.swyp.linkit.domain.chat.service.ChatService; import java.security.Principal; @@ -40,11 +39,9 @@ public void send(@Payload ChatSendRequestDto dto, Principal principal) { // 권한 체크 (room 참여자 여부) chatService.assertParticipant(senderId, roomId); - ChatMessage saved = chatService.saveMessage( + chatService.saveMessage( roomId, senderId, dto.getText(), dto.getMessageType(), dto.getImageUrl()); - - chatService.publishToRedis(saved); } /** @@ -94,11 +91,10 @@ public void markAsRead(@DestinationVariable Long roomId, Principal principal) { } /** - * 읽음 처리 공통 로직 (권한 체크 + 읽음 처리 + Redis 이벤트 발행) + * 읽음 처리 공통 로직 (권한 체크 + 읽음 처리 + Redis 이벤트 발행은 afterCommit에서 자동 처리) */ private void processReadAndNotify(Long roomId, Long userId) { chatService.assertParticipant(userId, roomId); chatService.markAsRead(roomId, userId); - chatService.publishReadEvent(roomId, userId, null); } } diff --git a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java index 4f1672b6..642a2ea6 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java +++ b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java @@ -7,6 +7,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.swyp.linkit.domain.chat.dto.ChatMessageDto; import org.swyp.linkit.domain.chat.dto.response.ChatPayloadResponseDto; import org.swyp.linkit.domain.chat.entity.*; @@ -18,7 +20,11 @@ import org.swyp.linkit.domain.notification.service.NotificationService; import org.swyp.linkit.domain.user.entity.User; import org.swyp.linkit.domain.user.repository.UserRepository; -import org.swyp.linkit.global.error.exception.*; +import org.swyp.linkit.global.error.exception.ChatInvalidMessageException; +import org.swyp.linkit.global.error.exception.ChatMessageNotFoundException; +import org.swyp.linkit.global.error.exception.ChatNotParticipantException; +import org.swyp.linkit.global.error.exception.ChatRoomNotFoundException; +import org.swyp.linkit.global.error.exception.UserNotFoundException; import java.time.ZoneOffset; import java.util.List; @@ -89,6 +95,26 @@ public ChatMessage saveMessage(Long roomId, Long senderId, String content, Long receiverId = senderRole == SenderRole.MENTOR ? room.getMenteeId() : room.getMentorId(); notificationService.createNotification(receiverId, senderId, NotificationType.CHAT_MESSAGE, roomId); + // 트랜잭션 커밋 후 Redis 발행 (Transactional Outbox 패턴) + ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder() + .roomId(roomId) + .messageId(saved.getId()) + .senderId(saved.getSenderId()) + .senderRole(saved.getSenderRole().name()) + .text(saved.getContent()) + .messageType(saved.getMessageType().name()) + .imageUrl(saved.getFileUrl()) + .sentAtEpochMs(saved.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli()) + .system(false) + .build(); + Long savedMessageId = saved.getId(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doPublishToRedis(roomId, savedMessageId, payload); + } + }); + log.info("메시지 저장: roomId={}, senderId={}, messageId={}, type={}", roomId, senderId, saved.getId(), messageType); return saved; } @@ -154,6 +180,15 @@ public void markAsRead(Long roomId, Long userId) { // Notification 기반 미읽음 알림 읽음 처리 notificationService.markChatRoomAsRead(userId, roomId); + // 트랜잭션 커밋 후 읽음 이벤트 Redis 발행 (Transactional Outbox 패턴) + Long lastReadId = lastMessage.getId(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doPublishReadEvent(roomId, userId, lastReadId); + } + }); + log.info("메시지 읽음 처리: roomId={}, userId={}, lastReadMessageId={}", roomId, userId, lastMessage.getId()); } @@ -185,38 +220,28 @@ public void deleteMessages(Long roomId, Long userId, List messageIds) { log.info("메시지 삭제: roomId={}, userId={}, count={}", roomId, userId, messageIds.size()); } + // === Private Helper Methods === + /** - * Redis Pub/Sub을 통해 메시지 발행 + * Redis Pub/Sub 메시지 발행 (afterCommit 내부 전용) + * afterCommit에서 발생하는 예외는 Spring이 억제하므로 로그로 대체 */ - public void publishToRedis(ChatMessage message) { - Long roomId = message.getChatRoom().getId(); - ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder() - .roomId(roomId) - .messageId(message.getId()) - .senderId(message.getSenderId()) - .senderRole(message.getSenderRole().name()) - .text(message.getContent()) - .messageType(message.getMessageType().name()) - .imageUrl(message.getFileUrl()) - .sentAtEpochMs(message.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli()) - .system(false) - .build(); - + private void doPublishToRedis(Long roomId, Long messageId, ChatPayloadResponseDto payload) { try { String json = objectMapper.writeValueAsString(payload); String channel = CHAT_CHANNEL_PREFIX + roomId; redisTemplate.convertAndSend(channel, json); - log.info("Redis 메시지 발행: channel={}, messageId={}", channel, message.getId()); + log.info("Redis 메시지 발행: channel={}, messageId={}", channel, messageId); } catch (JsonProcessingException e) { - log.error("채팅 메시지 직렬화 실패: roomId={}, messageId={}", roomId, message.getId(), e); - throw new ChatPublishFailedException(roomId); + log.error("채팅 메시지 직렬화 실패: roomId={}, messageId={}", roomId, messageId, e); } } /** - * 읽음 처리 이벤트 Redis 발행 + * 읽음 이벤트 Redis 발행 (afterCommit 내부 전용) + * afterCommit에서 발생하는 예외는 Spring이 억제하므로 로그로 대체 */ - public void publishReadEvent(Long roomId, Long userId, Long lastReadMessageId) { + private void doPublishReadEvent(Long roomId, Long userId, Long lastReadMessageId) { ChatPayloadResponseDto payload = ChatPayloadResponseDto.builder() .roomId(roomId) .readerId(userId) @@ -231,12 +256,9 @@ public void publishReadEvent(Long roomId, Long userId, Long lastReadMessageId) { log.info("읽음 이벤트 발행: channel={}, readerId={}", channel, userId); } catch (JsonProcessingException e) { log.error("읽음 이벤트 직렬화 실패: roomId={}, userId={}", roomId, userId, e); - throw new ChatPublishFailedException(roomId); } } - // === Private Helper Methods === - private User findUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("User not found: " + userId)); From 2a5bc3637674650364809bda88716ecb10161ebd Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 14 Apr 2026 20:52:22 +0900 Subject: [PATCH 19/20] =?UTF-8?q?fix=20:=20ChatService=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chatMessageRepository.flush() 추가로 save 후 @CreatedDate 필드 즉시 반영 보장 (NPE 방지) - saveMessage/markAsRead의 TransactionSynchronization 등록 전 isActualTransactionActive() 체크 추가 (트랜잭션 없는 환경에서 IllegalStateException 방지, 즉시 Redis 발행으로 폴백) Co-Authored-By: Claude Sonnet 4.6 --- .../domain/chat/service/ChatService.java | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java index 642a2ea6..34c69041 100644 --- a/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java +++ b/src/main/java/org/swyp/linkit/domain/chat/service/ChatService.java @@ -88,6 +88,7 @@ public ChatMessage saveMessage(Long roomId, Long senderId, String content, ChatMessage message = ChatMessage.create(room, sender, senderRole, content, messageType, fileUrl); ChatMessage saved = chatMessageRepository.save(message); + chatMessageRepository.flush(); room.updateLastMessage(saved.getId(), saved.getCreatedAt()); @@ -108,12 +109,16 @@ public ChatMessage saveMessage(Long roomId, Long senderId, String content, .system(false) .build(); Long savedMessageId = saved.getId(); - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - doPublishToRedis(roomId, savedMessageId, payload); - } - }); + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doPublishToRedis(roomId, savedMessageId, payload); + } + }); + } else { + doPublishToRedis(roomId, savedMessageId, payload); + } log.info("메시지 저장: roomId={}, senderId={}, messageId={}, type={}", roomId, senderId, saved.getId(), messageType); return saved; @@ -182,12 +187,16 @@ public void markAsRead(Long roomId, Long userId) { // 트랜잭션 커밋 후 읽음 이벤트 Redis 발행 (Transactional Outbox 패턴) Long lastReadId = lastMessage.getId(); - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - doPublishReadEvent(roomId, userId, lastReadId); - } - }); + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + doPublishReadEvent(roomId, userId, lastReadId); + } + }); + } else { + doPublishReadEvent(roomId, userId, lastReadId); + } log.info("메시지 읽음 처리: roomId={}, userId={}, lastReadMessageId={}", roomId, userId, lastMessage.getId()); } From caae131129179102515c617104d8d0844b1cfeab Mon Sep 17 00:00:00 2001 From: rorrxr Date: Tue, 14 Apr 2026 20:58:33 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix=20:=20ChatServiceTest=20ObjectMapper?= =?UTF-8?q?=20Mock=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveMessage 내부에서 트랜잭션 없는 테스트 환경 시 doPublishToRedis가 즉시 호출되어 objectMapper.writeValueAsString()에서 NPE 발생. ObjectMapper를 @Mock으로 등록하여 해결. Co-Authored-By: Claude Sonnet 4.6 --- .../org/swyp/linkit/domain/chat/service/ChatServiceTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/org/swyp/linkit/domain/chat/service/ChatServiceTest.java b/src/test/java/org/swyp/linkit/domain/chat/service/ChatServiceTest.java index 437a2168..c85c721e 100644 --- a/src/test/java/org/swyp/linkit/domain/chat/service/ChatServiceTest.java +++ b/src/test/java/org/swyp/linkit/domain/chat/service/ChatServiceTest.java @@ -1,5 +1,6 @@ package org.swyp.linkit.domain.chat.service; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -64,6 +65,9 @@ class ChatServiceTest { @Mock private NotificationService notificationService; + @Mock + private ObjectMapper objectMapper; + private ChatRoom chatRoom; private Long mentorId; private Long menteeId;