diff --git a/backend/src/main/java/com/openisle/service/ReactionService.java b/backend/src/main/java/com/openisle/service/ReactionService.java index 93b764f33..b99066a9b 100644 --- a/backend/src/main/java/com/openisle/service/ReactionService.java +++ b/backend/src/main/java/com/openisle/service/ReactionService.java @@ -1,5 +1,8 @@ package com.openisle.service; +import com.openisle.dto.MessageNotificationPayload; +import com.openisle.dto.ReactionDto; +import com.openisle.mapper.ReactionMapper; import com.openisle.model.Comment; import com.openisle.model.Message; import com.openisle.model.NotificationType; @@ -12,15 +15,17 @@ import com.openisle.repository.MessageRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.UserRepository; -import com.openisle.service.EmailSender; -import com.openisle.service.NotificationService; +import java.util.HashMap; +import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class ReactionService { private final ReactionRepository reactionRepository; @@ -29,6 +34,8 @@ public class ReactionService { private final CommentRepository commentRepository; private final MessageRepository messageRepository; private final NotificationService notificationService; + private final NotificationProducer notificationProducer; + private final ReactionMapper reactionMapper; private final EmailSender emailSender; @Value("${app.website-url}") @@ -124,18 +131,45 @@ public class ReactionService { message, type ); + + Map syncPayload = new HashMap<>(); + syncPayload.put("eventType", "MESSAGE_REACTION"); + syncPayload.put("conversationId", message.getConversation().getId()); + syncPayload.put("messageId", message.getId()); + if (existing.isPresent()) { - reactionRepository.delete(existing.get()); + Reaction removed = existing.get(); + ReactionDto removedDto = reactionMapper.toDto(removed); + reactionRepository.delete(removed); + + syncPayload.put("action", "REMOVED"); + syncPayload.put("reaction", removedDto); + sendMessageReactionSync(user.getUsername(), syncPayload); + return null; } + Reaction reaction = new Reaction(); reaction.setUser(user); reaction.setMessage(message); reaction.setType(type); reaction = reactionRepository.save(reaction); + + syncPayload.put("action", "ADDED"); + syncPayload.put("reaction", reactionMapper.toDto(reaction)); + sendMessageReactionSync(user.getUsername(), syncPayload); + return reaction; } + private void sendMessageReactionSync(String shardUsername, Map payload) { + try { + notificationProducer.sendNotification(new MessageNotificationPayload(shardUsername, payload)); + } catch (Exception e) { + log.error("Failed to broadcast message reaction sync via RabbitMQ", e); + } + } + public java.util.List getReactionsForPost(Long postId) { Post post = postRepository .findById(postId) diff --git a/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java b/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java index 217dde90f..54c407b7e 100644 --- a/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/ReactionControllerTest.java @@ -14,6 +14,7 @@ import com.openisle.model.Reaction; import com.openisle.model.ReactionType; import com.openisle.model.User; import com.openisle.service.LevelService; +import com.openisle.service.PointService; import com.openisle.service.ReactionService; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -39,6 +40,9 @@ class ReactionControllerTest { @MockBean private LevelService levelService; + @MockBean + private PointService pointService; + @Test void reactToPost() throws Exception { User user = new User(); diff --git a/backend/src/test/java/com/openisle/service/ReactionServiceTest.java b/backend/src/test/java/com/openisle/service/ReactionServiceTest.java index 6adb53500..90597389c 100644 --- a/backend/src/test/java/com/openisle/service/ReactionServiceTest.java +++ b/backend/src/test/java/com/openisle/service/ReactionServiceTest.java @@ -1,22 +1,32 @@ package com.openisle.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.*; +import com.openisle.dto.MessageNotificationPayload; +import com.openisle.dto.ReactionDto; +import com.openisle.mapper.ReactionMapper; import com.openisle.model.*; import com.openisle.repository.*; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; class ReactionServiceTest { @Test - void reactToPostSendsEmailEveryFive() { + void reactToPostCreatesNotificationForAuthor() { ReactionRepository reactionRepo = mock(ReactionRepository.class); UserRepository userRepo = mock(UserRepository.class); PostRepository postRepo = mock(PostRepository.class); CommentRepository commentRepo = mock(CommentRepository.class); MessageRepository messageRepo = mock(MessageRepository.class); NotificationService notif = mock(NotificationService.class); + NotificationProducer notificationProducer = mock(NotificationProducer.class); + ReactionMapper reactionMapper = new ReactionMapper(); EmailSender email = mock(EmailSender.class); ReactionService service = new ReactionService( reactionRepo, @@ -25,14 +35,10 @@ class ReactionServiceTest { commentRepo, messageRepo, notif, + notificationProducer, + reactionMapper, email ); - org.springframework.test.util.ReflectionTestUtils.setField( - service, - "websiteUrl", - "https://ex.com" - ); - User user = new User(); user.setId(1L); user.setUsername("bob"); @@ -49,11 +55,162 @@ class ReactionServiceTest { Optional.empty() ); when(reactionRepo.save(any(Reaction.class))).thenAnswer(i -> i.getArgument(0)); - when(reactionRepo.countReceived(author.getUsername())).thenReturn(5L); service.reactToPost("bob", 3L, ReactionType.LIKE); - verify(email).sendEmail("a@a.com", "你有新的互动", "https://ex.com/messages"); - verify(notif).sendCustomPush(author, "你有新的互动", "https://ex.com/messages"); + verify(notif).createNotification( + eq(author), + eq(NotificationType.REACTION), + eq(post), + isNull(), + isNull(), + eq(user), + eq(ReactionType.LIKE), + isNull() + ); + verifyNoInteractions(email); + } + + @Test + void reactToMessageBroadcastsAddedEvent() { + ReactionRepository reactionRepo = mock(ReactionRepository.class); + UserRepository userRepo = mock(UserRepository.class); + PostRepository postRepo = mock(PostRepository.class); + CommentRepository commentRepo = mock(CommentRepository.class); + MessageRepository messageRepo = mock(MessageRepository.class); + NotificationService notif = mock(NotificationService.class); + NotificationProducer notificationProducer = mock(NotificationProducer.class); + ReactionMapper reactionMapper = new ReactionMapper(); + EmailSender email = mock(EmailSender.class); + ReactionService service = new ReactionService( + reactionRepo, + userRepo, + postRepo, + commentRepo, + messageRepo, + notif, + notificationProducer, + reactionMapper, + email + ); + + User user = new User(); + user.setId(10L); + user.setUsername("alice"); + MessageConversation conversation = new MessageConversation(); + conversation.setId(20L); + Message message = new Message(); + message.setId(30L); + message.setConversation(conversation); + + when(userRepo.findByUsername("alice")).thenReturn(Optional.of(user)); + when(messageRepo.findById(30L)).thenReturn(Optional.of(message)); + when(reactionRepo.findByUserAndMessageAndType(user, message, ReactionType.LIKE)).thenReturn( + Optional.empty() + ); + when(reactionRepo.save(any(Reaction.class))).thenAnswer(invocation -> { + Reaction saved = invocation.getArgument(0); + saved.setId(40L); + return saved; + }); + + Reaction result = service.reactToMessage("alice", 30L, ReactionType.LIKE); + + assertEquals(40L, result.getId()); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass( + MessageNotificationPayload.class + ); + verify(notificationProducer).sendNotification(payloadCaptor.capture()); + + MessageNotificationPayload outbound = payloadCaptor.getValue(); + assertEquals("alice", outbound.getTargetUsername()); + + Object payloadObject = outbound.getPayload(); + assertInstanceOf(Map.class, payloadObject); + Map payload = (Map) payloadObject; + assertEquals("MESSAGE_REACTION", payload.get("eventType")); + assertEquals(20L, payload.get("conversationId")); + assertEquals(30L, payload.get("messageId")); + assertEquals("ADDED", payload.get("action")); + + Object reactionObject = payload.get("reaction"); + assertInstanceOf(ReactionDto.class, reactionObject); + ReactionDto reactionDto = (ReactionDto) reactionObject; + assertEquals(40L, reactionDto.getId()); + assertEquals("alice", reactionDto.getUser()); + assertEquals(30L, reactionDto.getMessageId()); + assertEquals(ReactionType.LIKE, reactionDto.getType()); + } + + @Test + void reactToMessageBroadcastsRemovedEvent() { + ReactionRepository reactionRepo = mock(ReactionRepository.class); + UserRepository userRepo = mock(UserRepository.class); + PostRepository postRepo = mock(PostRepository.class); + CommentRepository commentRepo = mock(CommentRepository.class); + MessageRepository messageRepo = mock(MessageRepository.class); + NotificationService notif = mock(NotificationService.class); + NotificationProducer notificationProducer = mock(NotificationProducer.class); + ReactionMapper reactionMapper = new ReactionMapper(); + EmailSender email = mock(EmailSender.class); + ReactionService service = new ReactionService( + reactionRepo, + userRepo, + postRepo, + commentRepo, + messageRepo, + notif, + notificationProducer, + reactionMapper, + email + ); + + User user = new User(); + user.setId(10L); + user.setUsername("alice"); + MessageConversation conversation = new MessageConversation(); + conversation.setId(20L); + Message message = new Message(); + message.setId(30L); + message.setConversation(conversation); + Reaction existing = new Reaction(); + existing.setId(50L); + existing.setUser(user); + existing.setMessage(message); + existing.setType(ReactionType.LIKE); + + when(userRepo.findByUsername("alice")).thenReturn(Optional.of(user)); + when(messageRepo.findById(30L)).thenReturn(Optional.of(message)); + when(reactionRepo.findByUserAndMessageAndType(user, message, ReactionType.LIKE)).thenReturn( + Optional.of(existing) + ); + + Reaction result = service.reactToMessage("alice", 30L, ReactionType.LIKE); + + assertNull(result); + verify(reactionRepo).delete(existing); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass( + MessageNotificationPayload.class + ); + verify(notificationProducer).sendNotification(payloadCaptor.capture()); + + MessageNotificationPayload outbound = payloadCaptor.getValue(); + assertEquals("alice", outbound.getTargetUsername()); + + Object payloadObject = outbound.getPayload(); + assertInstanceOf(Map.class, payloadObject); + Map payload = (Map) payloadObject; + assertEquals("MESSAGE_REACTION", payload.get("eventType")); + assertEquals(20L, payload.get("conversationId")); + assertEquals(30L, payload.get("messageId")); + assertEquals("REMOVED", payload.get("action")); + + Object reactionObject = payload.get("reaction"); + assertInstanceOf(ReactionDto.class, reactionObject); + ReactionDto reactionDto = (ReactionDto) reactionObject; + assertEquals(50L, reactionDto.getId()); + assertEquals("alice", reactionDto.getUser()); + assertEquals(30L, reactionDto.getMessageId()); + assertEquals(ReactionType.LIKE, reactionDto.getType()); } } diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue index 5b2412d6d..2685014a3 100644 --- a/frontend_nuxt/pages/message-box/[id].vue +++ b/frontend_nuxt/pages/message-box/[id].vue @@ -447,6 +447,11 @@ const subscribeToConversation = () => { try { const parsedMessage = JSON.parse(message.body) + if (parsedMessage?.eventType === 'MESSAGE_REACTION') { + applyMessageReactionSync(parsedMessage) + return + } + if (parsedMessage.sender && parsedMessage.sender.id === currentUser.value.id) { return } @@ -472,6 +477,36 @@ const subscribeToConversation = () => { }) } +function applyMessageReactionSync(event) { + const targetMessageId = Number(event?.messageId) + if (!Number.isFinite(targetMessageId)) return + + const targetMessage = messages.value.find((msg) => Number(msg.id) === targetMessageId) + if (!targetMessage) return + + if (!Array.isArray(targetMessage.reactions)) { + targetMessage.reactions = [] + } + + const reaction = event?.reaction + if (!reaction?.type || !reaction?.user) return + + const sameReaction = (current) => current?.type === reaction.type && current?.user === reaction.user + if (event.action === 'REMOVED') { + targetMessage.reactions = targetMessage.reactions.filter((current) => !sameReaction(current)) + return + } + + if (event.action === 'ADDED') { + const existingIndex = targetMessage.reactions.findIndex((current) => sameReaction(current)) + if (existingIndex > -1) { + targetMessage.reactions.splice(existingIndex, 1, reaction) + } else { + targetMessage.reactions.push(reaction) + } + } +} + watch(isConnected, (newValue) => { if (newValue) { subscribeToConversation() diff --git a/websocket_service/src/main/java/com/openisle/websocket/listener/NotificationListener.java b/websocket_service/src/main/java/com/openisle/websocket/listener/NotificationListener.java index fa18536f3..2f316d0d1 100644 --- a/websocket_service/src/main/java/com/openisle/websocket/listener/NotificationListener.java +++ b/websocket_service/src/main/java/com/openisle/websocket/listener/NotificationListener.java @@ -55,8 +55,19 @@ public class NotificationListener { if (payloadObject instanceof Map) { Map payloadMap = (Map) payloadObject; + if ("MESSAGE_REACTION".equals(payloadMap.get("eventType"))) { + Object conversationIdObj = payloadMap.get("conversationId"); + if (conversationIdObj instanceof Number) { + Long conversationId = ((Number) conversationIdObj).longValue(); + String conversationDestination = "/topic/conversation/" + conversationId; + messagingTemplate.convertAndSend(conversationDestination, payloadMap); + log.info("Message reaction broadcasted to destination: {}", conversationDestination); + } else { + log.warn("Missing or invalid conversationId for reaction payload: {}", payloadMap); + } + } // 处理包含完整对话信息的消息 - 完全复制之前的WebSocket发送逻辑 - if (payloadMap.containsKey("message") && payloadMap.containsKey("conversation") && payloadMap.containsKey("senderId")) { + else if (payloadMap.containsKey("message") && payloadMap.containsKey("conversation") && payloadMap.containsKey("senderId")) { Object messageObj = payloadMap.get("message"); Map conversationInfo = (Map) payloadMap.get("conversation"); Long conversationId = ((Number) conversationInfo.get("id")).longValue(); @@ -111,4 +122,4 @@ public class NotificationListener { log.error("Failed to process and send message for user {}", username, e); } } -} \ No newline at end of file +}