diff --git a/backend/pom.xml b/backend/pom.xml index 56c4048eb..4193590b6 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -26,6 +26,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + org.slf4j slf4j-api diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index 9fabbacbd..5fca385de 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -92,7 +92,7 @@ public class SecurityConfig { cfg.setAllowedHeaders(List.of("*")); cfg.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/api/**", cfg); + source.registerCorsConfiguration("/**", cfg); return source; } @@ -104,6 +104,7 @@ public class SecurityConfig { .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/ws/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() @@ -172,7 +173,7 @@ public class SecurityConfig { response.getWriter().write("{\"error\": \"Invalid or expired token\"}"); return; } - } else if (!uri.startsWith("/api/auth") && !publicGet) { + } else if (!uri.startsWith("/api/auth") && !publicGet && !uri.startsWith("/ws")) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\"error\": \"Missing token\"}"); diff --git a/backend/src/main/java/com/openisle/config/WebSocketConfig.java b/backend/src/main/java/com/openisle/config/WebSocketConfig.java new file mode 100644 index 000000000..309921fb9 --- /dev/null +++ b/backend/src/main/java/com/openisle/config/WebSocketConfig.java @@ -0,0 +1,79 @@ +package com.openisle.config; + +import com.openisle.service.JwtService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue" + config.enableSimpleBroker("/topic", "/queue"); + // Set user destination prefix for personal messages + config.setUserDestinationPrefix("/user"); + // Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods. + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // Registers the "/ws" endpoint, enabling SockJS fallback options so that alternate transports may be used if WebSocket is not available. + registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + System.out.println("WebSocket CONNECT command received"); + String authHeader = accessor.getFirstNativeHeader("Authorization"); + System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing")); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + String username = jwtService.validateAndGetSubject(token); + System.out.println("JWT validated for user: " + username); + var userDetails = userDetailsService.loadUserByUsername(username); + Authentication auth = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + accessor.setUser(auth); + System.out.println("WebSocket user set: " + username); + } catch (Exception e) { + System.err.println("JWT validation failed: " + e.getMessage()); + } + } + } else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { + System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination()); + System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null")); + } + return message; + } + }); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java new file mode 100644 index 000000000..c599fc785 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/MessageController.java @@ -0,0 +1,117 @@ +package com.openisle.controller; + +import com.openisle.dto.ConversationDetailDto; +import com.openisle.dto.ConversationDto; +import com.openisle.dto.CreateConversationRequest; +import com.openisle.dto.CreateConversationResponse; +import com.openisle.dto.MessageDto; +import com.openisle.dto.UserSummaryDto; +import com.openisle.model.Message; +import com.openisle.model.MessageConversation; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.MessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/messages") +@RequiredArgsConstructor +public class MessageController { + + private final MessageService messageService; + private final UserRepository userRepository; + + // This is a placeholder for getting the current user's ID + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found")); + // In a real application, you would get this from the Authentication object + return user.getId(); + } + + @GetMapping("/conversations") + public ResponseEntity> getConversations(Authentication auth) { + List conversations = messageService.getConversations(getCurrentUserId(auth)); + return ResponseEntity.ok(conversations); + } + + @GetMapping("/conversations/{conversationId}") + public ResponseEntity getMessages(@PathVariable Long conversationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Authentication auth) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable); + return ResponseEntity.ok(conversationDetails); + } + + @PostMapping + public ResponseEntity sendMessage(@RequestBody MessageRequest req, Authentication auth) { + Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent()); + return ResponseEntity.ok(toDto(message)); + } + + @PostMapping("/conversations/{conversationId}/read") + public ResponseEntity markAsRead(@PathVariable Long conversationId, Authentication auth) { + messageService.markConversationAsRead(conversationId, getCurrentUserId(auth)); + return ResponseEntity.ok().build(); + } + + @PostMapping("/conversations") + public ResponseEntity findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) { + MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId()); + return ResponseEntity.ok(new CreateConversationResponse(conversation.getId())); + } + + private MessageDto toDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setCreatedAt(message.getCreatedAt()); + + dto.setConversationId(message.getConversation().getId()); + + UserSummaryDto senderDto = new UserSummaryDto(); + senderDto.setId(message.getSender().getId()); + senderDto.setUsername(message.getSender().getUsername()); + senderDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(senderDto); + + return dto; + } + + @GetMapping("/unread-count") + public ResponseEntity getUnreadCount(Authentication auth) { + return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth))); + } + + // A simple request DTO + static class MessageRequest { + private Long recipientId; + private String content; + + public Long getRecipientId() { + return recipientId; + } + + public void setRecipientId(Long recipientId) { + this.recipientId = recipientId; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java new file mode 100644 index 000000000..96f548c45 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java @@ -0,0 +1,13 @@ +package com.openisle.dto; + +import lombok.Data; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Data +public class ConversationDetailDto { + private Long id; + private List participants; + private Page messages; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/ConversationDto.java b/backend/src/main/java/com/openisle/dto/ConversationDto.java new file mode 100644 index 000000000..17796f2c9 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ConversationDto.java @@ -0,0 +1,17 @@ +package com.openisle.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +public class ConversationDto { + private Long id; + private MessageDto lastMessage; + private List participants; + private LocalDateTime createdAt; + private long unreadCount; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java b/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java new file mode 100644 index 000000000..611557360 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/CreateConversationRequest.java @@ -0,0 +1,8 @@ +package com.openisle.dto; + +import lombok.Data; + +@Data +public class CreateConversationRequest { + private Long recipientId; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java b/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java new file mode 100644 index 000000000..349f120f9 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/CreateConversationResponse.java @@ -0,0 +1,12 @@ +package com.openisle.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CreateConversationResponse { + private Long conversationId; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/MessageDto.java b/backend/src/main/java/com/openisle/dto/MessageDto.java new file mode 100644 index 000000000..ff536cf84 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/MessageDto.java @@ -0,0 +1,13 @@ +package com.openisle.dto; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class MessageDto { + private Long id; + private String content; + private UserSummaryDto sender; + private Long conversationId; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/dto/UserSummaryDto.java b/backend/src/main/java/com/openisle/dto/UserSummaryDto.java new file mode 100644 index 000000000..5df8254a4 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/UserSummaryDto.java @@ -0,0 +1,10 @@ +package com.openisle.dto; + +import lombok.Data; + +@Data +public class UserSummaryDto { + private Long id; + private String username; + private String avatar; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/Message.java b/backend/src/main/java/com/openisle/model/Message.java new file mode 100644 index 000000000..2cd1d4cca --- /dev/null +++ b/backend/src/main/java/com/openisle/model/Message.java @@ -0,0 +1,35 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "messages") +public class Message { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + private MessageConversation conversation; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/MessageConversation.java b/backend/src/main/java/com/openisle/model/MessageConversation.java new file mode 100644 index 000000000..9f9c94971 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/MessageConversation.java @@ -0,0 +1,36 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "message_conversations") +public class MessageConversation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_message_id") + private Message lastMessage; + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + private Set participants = new HashSet<>(); + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) + private Set messages = new HashSet<>(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/MessageParticipant.java b/backend/src/main/java/com/openisle/model/MessageParticipant.java new file mode 100644 index 000000000..d69901c8f --- /dev/null +++ b/backend/src/main/java/com/openisle/model/MessageParticipant.java @@ -0,0 +1,30 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "message_participants") +public class MessageParticipant { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + private MessageConversation conversation; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column + private LocalDateTime lastReadAt; +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java new file mode 100644 index 000000000..ef8bacb6c --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java @@ -0,0 +1,31 @@ +package com.openisle.repository; + +import com.openisle.model.MessageConversation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.openisle.model.User; +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import com.openisle.model.User; +import java.util.List; + +@Repository +public interface MessageConversationRepository extends JpaRepository { + @Query("SELECT c FROM MessageConversation c JOIN c.participants p1 JOIN c.participants p2 WHERE p1.user = :user1 AND p2.user = :user2") + Optional findConversationByUsers(@Param("user1") User user1, @Param("user2") User user2); + + @Query("SELECT DISTINCT c FROM MessageConversation c " + + "JOIN c.participants p " + + "LEFT JOIN FETCH c.lastMessage lm " + + "LEFT JOIN FETCH lm.sender " + + "LEFT JOIN FETCH c.participants cp " + + "LEFT JOIN FETCH cp.user " + + "WHERE p.user.id = :userId " + + "ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC") + List findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java b/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java new file mode 100644 index 000000000..3c63bccbd --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/MessageParticipantRepository.java @@ -0,0 +1,14 @@ +package com.openisle.repository; + +import com.openisle.model.MessageParticipant; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MessageParticipantRepository extends JpaRepository { + Optional findByConversationIdAndUserId(Long conversationId, Long userId); + List findByUserId(Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/repository/MessageRepository.java b/backend/src/main/java/com/openisle/repository/MessageRepository.java new file mode 100644 index 000000000..9c89a0247 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/MessageRepository.java @@ -0,0 +1,21 @@ +package com.openisle.repository; + +import com.openisle.model.Message; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MessageRepository extends JpaRepository { + List findByConversationIdOrderByCreatedAtAsc(Long conversationId); + + Page findByConversationId(Long conversationId, Pageable pageable); + + long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt); + + // 只计算不是指定用户发送的消息(即别人发给当前用户的消息) + long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java new file mode 100644 index 000000000..3e668742e --- /dev/null +++ b/backend/src/main/java/com/openisle/service/MessageService.java @@ -0,0 +1,217 @@ +package com.openisle.service; + +import com.openisle.model.Message; +import com.openisle.model.MessageConversation; +import com.openisle.model.MessageParticipant; +import com.openisle.model.User; +import com.openisle.repository.MessageConversationRepository; +import com.openisle.repository.MessageParticipantRepository; +import com.openisle.repository.MessageRepository; +import com.openisle.repository.UserRepository; +import com.openisle.dto.ConversationDetailDto; +import com.openisle.dto.ConversationDto; +import com.openisle.dto.MessageDto; +import com.openisle.dto.UserSummaryDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MessageService { + + private final MessageRepository messageRepository; + private final MessageConversationRepository conversationRepository; + private final MessageParticipantRepository participantRepository; + private final UserRepository userRepository; + private final SimpMessagingTemplate messagingTemplate; + + @Transactional + public Message sendMessage(Long senderId, Long recipientId, String content) { + log.info("Attempting to send message from user {} to user {}", senderId, recipientId); + User sender = userRepository.findById(senderId) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + User recipient = userRepository.findById(recipientId) + .orElseThrow(() -> new IllegalArgumentException("Recipient not found")); + + log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername()); + MessageConversation conversation = findOrCreateConversation(sender, recipient); + log.info("Conversation found or created with ID: {}", conversation.getId()); + + Message message = new Message(); + message.setConversation(conversation); + message.setSender(sender); + message.setContent(content); + message = messageRepository.save(message); + log.info("Message saved with ID: {}", message.getId()); + + conversation.setLastMessage(message); + conversationRepository.save(conversation); + log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId()); + + // Broadcast the new message to subscribed clients + MessageDto messageDto = toDto(message); + String conversationDestination = "/topic/conversation/" + conversation.getId(); + messagingTemplate.convertAndSend(conversationDestination, messageDto); + log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination); + + // Also notify the recipient on their personal channel to update the conversation list + String userDestination = "/topic/user/" + recipient.getId() + "/messages"; + messagingTemplate.convertAndSend(userDestination, messageDto); + log.info("Message {} notification sent to destination: {}", message.getId(), userDestination); + + // Notify recipient of new unread count + long unreadCount = getUnreadMessageCount(recipientId); + log.info("Calculating unread count for user {}: {}", recipientId, unreadCount); + + // Send using username instead of user ID for WebSocket routing + String recipientUsername = recipient.getUsername(); + messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount); + log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count", + unreadCount, recipientId, recipientUsername, recipientUsername); + + return message; + } + + private MessageDto toDto(Message message) { + MessageDto dto = new MessageDto(); + dto.setId(message.getId()); + dto.setContent(message.getContent()); + dto.setConversationId(message.getConversation().getId()); + dto.setCreatedAt(message.getCreatedAt()); + + UserSummaryDto userSummaryDto = new UserSummaryDto(); + userSummaryDto.setId(message.getSender().getId()); + userSummaryDto.setUsername(message.getSender().getUsername()); + userSummaryDto.setAvatar(message.getSender().getAvatar()); + dto.setSender(userSummaryDto); + + return dto; + } + + public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) { + User user1 = userRepository.findById(user1Id) + .orElseThrow(() -> new IllegalArgumentException("User1 not found")); + User user2 = userRepository.findById(user2Id) + .orElseThrow(() -> new IllegalArgumentException("User2 not found")); + return findOrCreateConversation(user1, user2); + } + + private MessageConversation findOrCreateConversation(User user1, User user2) { + log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername()); + return conversationRepository.findConversationByUsers(user1, user2) + .orElseGet(() -> { + log.info("No existing conversation found. Creating a new one."); + MessageConversation conversation = new MessageConversation(); + conversation = conversationRepository.save(conversation); + log.info("New conversation created with ID: {}", conversation.getId()); + + MessageParticipant participant1 = new MessageParticipant(); + participant1.setConversation(conversation); + participant1.setUser(user1); + participantRepository.save(participant1); + log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId()); + + MessageParticipant participant2 = new MessageParticipant(); + participant2.setConversation(conversation); + participant2.setUser(user2); + participantRepository.save(participant2); + log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId()); + + return conversation; + }); + } + + @Transactional(readOnly = true) + public List getConversations(Long userId) { + List conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId); + return conversations.stream().map(c -> toDto(c, userId)).collect(Collectors.toList()); + } + + private ConversationDto toDto(MessageConversation conversation, Long userId) { + ConversationDto dto = new ConversationDto(); + dto.setId(conversation.getId()); + dto.setCreatedAt(conversation.getCreatedAt()); + if (conversation.getLastMessage() != null) { + dto.setLastMessage(toDto(conversation.getLastMessage())); + } + dto.setParticipants(conversation.getParticipants().stream() + .map(p -> { + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(p.getUser().getId()); + userDto.setUsername(p.getUser().getUsername()); + userDto.setAvatar(p.getUser().getAvatar()); + return userDto; + }) + .collect(Collectors.toList())); + + MessageParticipant self = conversation.getParticipants().stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Participant not found in conversation")); + + LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt(); + // 只计算别人发送给当前用户的未读消息 + long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId); + dto.setUnreadCount(unreadCount); + + return dto; + } + + @Transactional + public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) { + markConversationAsRead(conversationId, userId); + + MessageConversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); + + Page messagesPage = messageRepository.findByConversationId(conversationId, pageable); + Page messageDtoPage = messagesPage.map(this::toDto); + + List participants = conversation.getParticipants().stream() + .map(p -> { + UserSummaryDto userDto = new UserSummaryDto(); + userDto.setId(p.getUser().getId()); + userDto.setUsername(p.getUser().getUsername()); + userDto.setAvatar(p.getUser().getAvatar()); + return userDto; + }) + .collect(Collectors.toList()); + + ConversationDetailDto detailDto = new ConversationDetailDto(); + detailDto.setId(conversation.getId()); + detailDto.setParticipants(participants); + detailDto.setMessages(messageDtoPage); + + return detailDto; + } + + @Transactional + public void markConversationAsRead(Long conversationId, Long userId) { + MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId) + .orElseThrow(() -> new IllegalArgumentException("Participant not found")); + participant.setLastReadAt(LocalDateTime.now()); + participantRepository.save(participant); + } + + @Transactional(readOnly = true) + public long getUnreadMessageCount(Long userId) { + List participations = participantRepository.findByUserId(userId); + long totalUnreadCount = 0; + for (MessageParticipant p : participations) { + LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt(); + // 只计算别人发送给当前用户的未读消息 + totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId); + } + return totalUnreadCount; + } +} \ No newline at end of file diff --git a/frontend_nuxt/components/HeaderComponent.vue b/frontend_nuxt/components/HeaderComponent.vue index 56f1b8299..2e7e9e46f 100644 --- a/frontend_nuxt/components/HeaderComponent.vue +++ b/frontend_nuxt/components/HeaderComponent.vue @@ -6,7 +6,7 @@ - + + +
+ + {{ unreadMessageCount }} +
+
+