From 09c019e70b95602740a57127b0cd034f9023be7e Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sat, 23 Aug 2025 01:23:48 +0800 Subject: [PATCH] feat: add channel tabs and chat support --- .../openisle/config/ChannelInitializer.java | 35 +++ .../controller/ChannelController.java | 82 +++++++ .../controller/MessageController.java | 20 ++ .../java/com/openisle/dto/ChannelDto.java | 16 ++ .../openisle/dto/ConversationDetailDto.java | 1 + .../com/openisle/dto/ConversationDto.java | 2 + .../main/java/com/openisle/model/Channel.java | 27 +++ .../repository/ChannelRepository.java | 10 + .../com/openisle/service/MessageService.java | 55 +++++ frontend_nuxt/pages/message-box/[id].vue | 36 ++-- frontend_nuxt/pages/message-box/index.vue | 200 ++++++++++++++---- 11 files changed, 431 insertions(+), 53 deletions(-) create mode 100644 backend/src/main/java/com/openisle/config/ChannelInitializer.java create mode 100644 backend/src/main/java/com/openisle/controller/ChannelController.java create mode 100644 backend/src/main/java/com/openisle/dto/ChannelDto.java create mode 100644 backend/src/main/java/com/openisle/model/Channel.java create mode 100644 backend/src/main/java/com/openisle/repository/ChannelRepository.java diff --git a/backend/src/main/java/com/openisle/config/ChannelInitializer.java b/backend/src/main/java/com/openisle/config/ChannelInitializer.java new file mode 100644 index 000000000..fa839de71 --- /dev/null +++ b/backend/src/main/java/com/openisle/config/ChannelInitializer.java @@ -0,0 +1,35 @@ +package com.openisle.config; + +import com.openisle.model.Channel; +import com.openisle.model.MessageConversation; +import com.openisle.repository.ChannelRepository; +import com.openisle.repository.MessageConversationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ChannelInitializer implements CommandLineRunner { + private final ChannelRepository channelRepository; + private final MessageConversationRepository conversationRepository; + + @Override + public void run(String... args) { + if (channelRepository.count() == 0) { + createChannel("吹水群", "闲聊讨论", "/default-avatar.svg"); + createChannel("技术讨论群", "技术交流", "/default-avatar.svg"); + } + } + + private void createChannel(String name, String description, String avatar) { + MessageConversation conversation = new MessageConversation(); + conversation = conversationRepository.save(conversation); + Channel channel = new Channel(); + channel.setName(name); + channel.setDescription(description); + channel.setAvatar(avatar); + channel.setConversation(conversation); + channelRepository.save(channel); + } +} diff --git a/backend/src/main/java/com/openisle/controller/ChannelController.java b/backend/src/main/java/com/openisle/controller/ChannelController.java new file mode 100644 index 000000000..278f343b9 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/ChannelController.java @@ -0,0 +1,82 @@ +package com.openisle.controller; + +import com.openisle.dto.ChannelDto; +import com.openisle.model.Channel; +import com.openisle.model.MessageParticipant; +import com.openisle.model.MessageConversation; +import com.openisle.model.User; +import com.openisle.repository.ChannelRepository; +import com.openisle.repository.MessageParticipantRepository; +import com.openisle.repository.UserRepository; +import com.openisle.repository.MessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/channels") +@RequiredArgsConstructor +public class ChannelController { + private final ChannelRepository channelRepository; + private final MessageParticipantRepository participantRepository; + private final UserRepository userRepository; + private final MessageRepository messageRepository; + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("User not found")); + return user.getId(); + } + + @GetMapping + public ResponseEntity> listChannels(Authentication auth) { + Long userId = auth == null ? null : getCurrentUserId(auth); + List channels = channelRepository.findAll().stream() + .map(c -> toDto(c, userId)) + .collect(Collectors.toList()); + return ResponseEntity.ok(channels); + } + + @PostMapping("/{id}/join") + public ResponseEntity joinChannel(@PathVariable Long id, Authentication auth) { + Channel channel = channelRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("Channel not found")); + Long userId = getCurrentUserId(auth); + boolean exists = channel.getConversation().getParticipants().stream().anyMatch(p -> p.getUser().getId().equals(userId)); + if (!exists) { + MessageParticipant participant = new MessageParticipant(); + participant.setConversation(channel.getConversation()); + participant.setUser(userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found"))); + participantRepository.save(participant); + } + return ResponseEntity.ok().build(); + } + + private ChannelDto toDto(Channel channel, Long userId) { + ChannelDto dto = new ChannelDto(); + dto.setId(channel.getId()); + dto.setName(channel.getName()); + dto.setDescription(channel.getDescription()); + dto.setAvatar(channel.getAvatar()); + if (channel.getConversation() != null) { + MessageConversation conversation = channel.getConversation(); + dto.setConversationId(conversation.getId()); + dto.setMemberCount(conversation.getParticipants().size()); + if (userId != null) { + MessageParticipant self = conversation.getParticipants().stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst().orElse(null); + if (self != null) { + var lastRead = self.getLastReadAt(); + dto.setUnreadCount(messageRepository + .countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), + lastRead == null ? java.time.LocalDateTime.of(1970,1,1,0,0) : lastRead, + userId)); + } + } + } + return dto; + } +} diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java index c599fc785..8e6b11bb6 100644 --- a/backend/src/main/java/com/openisle/controller/MessageController.java +++ b/backend/src/main/java/com/openisle/controller/MessageController.java @@ -59,6 +59,14 @@ public class MessageController { return ResponseEntity.ok(toDto(message)); } + @PostMapping("/conversations/{conversationId}/messages") + public ResponseEntity sendMessageToConversation(@PathVariable Long conversationId, + @RequestBody ContentRequest req, + Authentication auth) { + Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent()); + return ResponseEntity.ok(toDto(message)); + } + @PostMapping("/conversations/{conversationId}/read") public ResponseEntity markAsRead(@PathVariable Long conversationId, Authentication auth) { messageService.markConversationAsRead(conversationId, getCurrentUserId(auth)); @@ -114,4 +122,16 @@ public class MessageController { this.content = content; } } + + static class ContentRequest { + private String content; + + 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/ChannelDto.java b/backend/src/main/java/com/openisle/dto/ChannelDto.java new file mode 100644 index 000000000..a69adf125 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ChannelDto.java @@ -0,0 +1,16 @@ +package com.openisle.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ChannelDto { + private Long id; + private String name; + private String description; + private String avatar; + private Long conversationId; + private int memberCount; + private long unreadCount; +} diff --git a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java index 96f548c45..918623dfe 100644 --- a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java +++ b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java @@ -10,4 +10,5 @@ public class ConversationDetailDto { private Long id; private List participants; private Page messages; + private ChannelDto channel; } \ 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 index 17796f2c9..a16233018 100644 --- a/backend/src/main/java/com/openisle/dto/ConversationDto.java +++ b/backend/src/main/java/com/openisle/dto/ConversationDto.java @@ -3,6 +3,7 @@ package com.openisle.dto; import lombok.Getter; import lombok.Setter; + import java.time.LocalDateTime; import java.util.List; @@ -14,4 +15,5 @@ public class ConversationDto { private List participants; private LocalDateTime createdAt; private long unreadCount; + private ChannelDto channel; } \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/model/Channel.java b/backend/src/main/java/com/openisle/model/Channel.java new file mode 100644 index 000000000..dbfc4b05e --- /dev/null +++ b/backend/src/main/java/com/openisle/model/Channel.java @@ -0,0 +1,27 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "channels") +@Getter +@Setter +@NoArgsConstructor +public class Channel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private String description; + + private String avatar; + + @OneToOne + @JoinColumn(name = "conversation_id") + private MessageConversation conversation; +} diff --git a/backend/src/main/java/com/openisle/repository/ChannelRepository.java b/backend/src/main/java/com/openisle/repository/ChannelRepository.java new file mode 100644 index 000000000..04e09bc9f --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/ChannelRepository.java @@ -0,0 +1,10 @@ +package com.openisle.repository; + +import com.openisle.model.Channel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ChannelRepository extends JpaRepository { + Optional findByConversationId(Long conversationId); +} diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java index 3e668742e..2f2985e88 100644 --- a/backend/src/main/java/com/openisle/service/MessageService.java +++ b/backend/src/main/java/com/openisle/service/MessageService.java @@ -8,6 +8,9 @@ import com.openisle.repository.MessageConversationRepository; import com.openisle.repository.MessageParticipantRepository; import com.openisle.repository.MessageRepository; import com.openisle.repository.UserRepository; +import com.openisle.repository.ChannelRepository; +import com.openisle.model.Channel; +import com.openisle.dto.ChannelDto; import com.openisle.dto.ConversationDetailDto; import com.openisle.dto.ConversationDto; import com.openisle.dto.MessageDto; @@ -33,6 +36,7 @@ public class MessageService { private final MessageConversationRepository conversationRepository; private final MessageParticipantRepository participantRepository; private final UserRepository userRepository; + private final ChannelRepository channelRepository; private final SimpMessagingTemplate messagingTemplate; @Transactional @@ -82,6 +86,39 @@ public class MessageService { return message; } + @Transactional + public Message sendMessageToConversation(Long senderId, Long conversationId, String content) { + User sender = userRepository.findById(senderId) + .orElseThrow(() -> new IllegalArgumentException("Sender not found")); + MessageConversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("Conversation not found")); + + Message message = new Message(); + message.setConversation(conversation); + message.setSender(sender); + message.setContent(content); + message = messageRepository.save(message); + + conversation.setLastMessage(message); + conversationRepository.save(conversation); + + MessageDto messageDto = toDto(message); + String conversationDestination = "/topic/conversation/" + conversation.getId(); + messagingTemplate.convertAndSend(conversationDestination, messageDto); + + conversation.getParticipants().forEach(p -> { + if (!p.getUser().getId().equals(senderId)) { + String userDestination = "/topic/user/" + p.getUser().getId() + "/messages"; + messagingTemplate.convertAndSend(userDestination, messageDto); + long unreadCount = getUnreadMessageCount(p.getUser().getId()); + String recipientUsername = p.getUser().getUsername(); + messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount); + } + }); + + return message; + } + private MessageDto toDto(Message message) { MessageDto dto = new MessageDto(); dto.setId(message.getId()); @@ -98,6 +135,19 @@ public class MessageService { return dto; } + private ChannelDto toDto(Channel channel) { + ChannelDto dto = new ChannelDto(); + dto.setId(channel.getId()); + dto.setName(channel.getName()); + dto.setDescription(channel.getDescription()); + dto.setAvatar(channel.getAvatar()); + if (channel.getConversation() != null) { + dto.setConversationId(channel.getConversation().getId()); + dto.setMemberCount(channel.getConversation().getParticipants().size()); + } + return dto; + } + public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) { User user1 = userRepository.findById(user1Id) .orElseThrow(() -> new IllegalArgumentException("User1 not found")); @@ -154,6 +204,9 @@ public class MessageService { }) .collect(Collectors.toList())); + channelRepository.findByConversationId(conversation.getId()) + .ifPresent(channel -> dto.setChannel(toDto(channel))); + MessageParticipant self = conversation.getParticipants().stream() .filter(p -> p.getUser().getId().equals(userId)) .findFirst() @@ -191,6 +244,8 @@ public class MessageService { detailDto.setId(conversation.getId()); detailDto.setParticipants(participants); detailDto.setMessages(messageDtoPage); + channelRepository.findByConversationId(conversation.getId()) + .ifPresent(channel -> detailDto.setChannel(toDto(channel))); return detailDto; } diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue index af0086974..ccead2440 100644 --- a/frontend_nuxt/pages/message-box/[id].vue +++ b/frontend_nuxt/pages/message-box/[id].vue @@ -1,10 +1,10 @@