From 1b892828f13ceb80ba01de3aa268077ce0afb62a Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:23:22 +0800 Subject: [PATCH 01/62] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e1d721d01..16e4516b7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@

+ Image OpenIsle

高效的开源社区前后端端平台 From 06ffb180fedfc33fad66a38f085977a9e8f1c9bd Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:24:05 +0800 Subject: [PATCH 02/62] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 16e4516b7..362c7f841 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@

- Image OpenIsle

高效的开源社区前后端端平台

- + Image

## 💡 简介 From f60f184c8448123c121d4cef85fe9cfe702bf04f Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:24:33 +0800 Subject: [PATCH 03/62] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 362c7f841..60ed15bb0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@

OpenIsle -

高效的开源社区前后端端平台 -

+


Image

From fa1148bc4e4cc8208346c4b135dff34403cb9521 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:25:12 +0800 Subject: [PATCH 04/62] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 60ed15bb0..f6552472d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@

OpenIsle +
高效的开源社区前后端端平台


Image From 66035447a8a054e8add0f791b36d413791265651 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 20 Aug 2025 16:28:28 +0800 Subject: [PATCH 05/62] feat: add CONTRIBUTING --- frontend_nuxt/CONTRIBUTING.md | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 frontend_nuxt/CONTRIBUTING.md diff --git a/frontend_nuxt/CONTRIBUTING.md b/frontend_nuxt/CONTRIBUTING.md new file mode 100644 index 000000000..9b9804d2c --- /dev/null +++ b/frontend_nuxt/CONTRIBUTING.md @@ -0,0 +1,116 @@ +#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务** + +## 如何部署 + +> Step1 先克隆仓库 + +```shell +git clone https://github.com/nagisa77/OpenIsle.git +cd OpenIsle +``` + +> Step2 后端部署 + +```shell +cd backend +``` + +以IDEA编辑器为例,IDEA打开backend文件夹。 + +- 设置VM Option,最好运行在其他端口,非8080,这里设置8081 + +```shell +-Dserver.port=8081 +``` + +![CleanShot 2025-08-04 at 11 .35.49.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/4cf210cfc6ea478a80dfc744c85ccdc4.png) + +- 设置jdk版本为java 17 + +![CleanShot 2025-08-04 at 11 .38.03@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/392eeec753ae436ca12a78f750dfea2d.png) + +- 本机配置MySQL服务(网上很多教程,忽略) +- 设置环境变量.env 文件 或.properties 文件(二选一) + +1. 环境变量文件生成 + +```shell +cp open-isle.env.example open-isle.env +``` + +修改环境变量,留下需要的,比如你要开发Google登录业务,就需要谷歌相关的变量,数据库是一定要的 + +![CleanShot 2025-08-04 at 11 .41.36@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/896c8363b6e64ea19d18c12ec4dae2b4.png) + +应用环境文件, 选择刚刚的`open-isle.env` + +![CleanShot 2025-08-04 at 11 .44.41.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f588e37838014a6684c141605639b9fa.png) + +2. 直接修改 .properities 文件 + +位置src/main/application.properties, 数据库需要修改标红处,其他按需修改 + +![CleanShot 2025-08-04 at 11 .47.11@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/28c3104448a245419e0b06aee861abb4.png) + +处理完环境问题直接跑起来就能通了 + +![CleanShot 2025-08-04 at 11 .49.01@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2c945eae44b1477db09e80fc96b5e02d.png) + +> Step3 前端部署 + +前端可以依赖本机部署的后端,也可以直接调用线上的后端接口 + +```shell +cd ../frontend_nuxt/ +``` + +copy环境.env文件 + +```shell +cp .env.staging.example .env +``` + +1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口 + +```yaml +; 本地部署后端 +NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 +; 预发环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com +; 生产环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +``` + +2. 依赖预发环境后台环境 + +**(⚠️强烈推荐只部署前端的朋友使用该环境)** + +```yaml +; 本地部署后端 +; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 +; 预发环境后端 +NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com +; 生产环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +``` + +4. 依赖线上后台环境 + +```yaml +; 本地部署后端 +; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081 +; 预发环境后端 +; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com +; 生产环境后端 +NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com +``` + +```shell +# 安装依赖 +npm install --verbose + +# 运行前端服务 +npm run dev +``` + +如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面 From d717ce03c16f4b9ee4a556a1575ecad21b66d573 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 20 Aug 2025 16:29:45 +0800 Subject: [PATCH 06/62] feat: add CONTRIBUTING --- frontend_nuxt/CONTRIBUTING.md => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend_nuxt/CONTRIBUTING.md => CONTRIBUTING.md (100%) diff --git a/frontend_nuxt/CONTRIBUTING.md b/CONTRIBUTING.md similarity index 100% rename from frontend_nuxt/CONTRIBUTING.md rename to CONTRIBUTING.md From 81e3a80d351a4a19c037e6704e6dd6638550a36a Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:31:49 +0800 Subject: [PATCH 07/62] Update README.md --- README.md | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f6552472d..8fa54b96b 100644 --- a/README.md +++ b/README.md @@ -10,36 +10,9 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。 -## 🚧 开发 +## 🚧 开发 & 部署 -### 后端 - -1. 确保安装 JDK 17 及 Maven -2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数 -3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run` - -### 前端 - -1. 进入前端目录 - ```bash - cd frontend_nuxt - ``` -2. 安装依赖 - ```bash - npm install - ``` -3. 启动开发服务 - ```bash - npm run dev - ``` - - 生产版本使用如下命令编译: - - ```bash - npm run build - ``` - - 会在 `.output` 目录生成文件,配合线上网站方式部署 +详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file) ## ✨ 项目特点 From 4969a759aa3d3043d05025eb4a1bbb8e2d533b52 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 20 Aug 2025 19:33:31 +0800 Subject: [PATCH 08/62] =?UTF-8?q?fix:=20=E5=B7=B2=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E7=9A=84=E5=B8=96=E5=AD=90=E4=B8=8D=E9=9C=80=E8=A6=81=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E8=AE=A2=E9=98=85=E6=8C=89=E9=92=AE=20#651?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/posts/[id]/index.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 128b8a208..b117e6e20 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -17,7 +17,7 @@

已拒绝
已关闭
@@ -27,7 +27,7 @@
@@ -1067,6 +1067,7 @@ onMounted(async () => { white-space: nowrap; } +.article-closed-button, .article-subscribe-button-text, .article-unsubscribe-button-text { white-space: nowrap; From 91ffacc3358886c785cd23bb5f940b0ed9b48437 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 20 Aug 2025 19:43:31 +0800 Subject: [PATCH 09/62] =?UTF-8?q?fix:=20=E5=B7=B2=E7=BB=8F=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=9A=84=E5=B8=96=E5=AD=90=20=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=20=E6=B2=A1=E6=9C=89=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=AE=9A=E4=BD=8D=E9=80=BB=E8=BE=91=20#652?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/posts/[id]/index.vue | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index b117e6e20..45f4e9bbe 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -876,12 +876,7 @@ const gotoProfile = () => { navigateTo(`/users/${author.value.id}`, { replace: true }) } -onActivated(async () => { - await refreshPost() - await fetchComments() -}) - -onMounted(async () => { +const initPage = async () => { await fetchComments() const hash = location.hash const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null @@ -889,6 +884,14 @@ onMounted(async () => { updateCurrentIndex() window.addEventListener('scroll', updateCurrentIndex) jumpToHashComment() +} + +onActivated(async () => { + await initPage() +}) + +onMounted(async () => { + await initPage() }) From 959b0f6a489e5e63a21d794b77a13547e2022e97 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:21:31 +0800 Subject: [PATCH 10/62] feat: notify authors when admin deletes post --- .../com/openisle/model/NotificationType.java | 2 + .../com/openisle/service/PostService.java | 9 +++- .../com/openisle/service/PostServiceTest.java | 52 +++++++++++++++++++ frontend_nuxt/pages/message.vue | 20 +++++++ frontend_nuxt/utils/notification.js | 13 +++++ 5 files changed, 95 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index 132af5176..d8e3a99a1 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -14,6 +14,8 @@ public enum NotificationType { POST_REVIEW_REQUEST, /** Your post under review was approved or rejected */ POST_REVIEWED, + /** An administrator deleted your post */ + POST_DELETED, /** A subscribed post received a new comment */ POST_UPDATED, /** Someone subscribed to your post */ diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index e9a205fb9..f0238d29e 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -579,7 +579,9 @@ public class PostService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { + User author = post.getAuthor(); + boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN; + if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { @@ -596,7 +598,12 @@ public class PostService { future.cancel(false); } } + String title = post.getTitle(); postRepository.delete(post); + if (adminDeleting) { + notificationService.createNotification(author, NotificationType.POST_DELETED, + null, null, null, user, null, title); + } } public java.util.List getPostsByIds(java.util.List ids) { diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index e1dbfd297..4ad621b18 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -61,6 +61,58 @@ class PostServiceTest { verify(postRepo).delete(post); } + @Test + void deletePostByAdminNotifiesAuthor() { + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + CategoryRepository catRepo = mock(CategoryRepository.class); + TagRepository tagRepo = mock(TagRepository.class); + LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); + NotificationService notifService = mock(NotificationService.class); + SubscriptionService subService = mock(SubscriptionService.class); + CommentService commentService = mock(CommentService.class); + CommentRepository commentRepo = mock(CommentRepository.class); + ReactionRepository reactionRepo = mock(ReactionRepository.class); + PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); + NotificationRepository notificationRepo = mock(NotificationRepository.class); + PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); + TaskScheduler taskScheduler = mock(TaskScheduler.class); + EmailSender emailSender = mock(EmailSender.class); + ApplicationContext context = mock(ApplicationContext.class); + + PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, + notifService, subService, commentService, commentRepo, + reactionRepo, subRepo, notificationRepo, postReadService, + imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT); + when(context.getBean(PostService.class)).thenReturn(service); + + Post post = new Post(); + post.setId(1L); + post.setTitle("T"); + post.setContent(""); + User author = new User(); + author.setId(2L); + author.setRole(Role.USER); + post.setAuthor(author); + + User admin = new User(); + admin.setId(1L); + admin.setRole(Role.ADMIN); + + when(postRepo.findById(1L)).thenReturn(Optional.of(post)); + when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin)); + when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of()); + when(reactionRepo.findByPost(post)).thenReturn(List.of()); + when(subRepo.findByPost(post)).thenReturn(List.of()); + when(notificationRepo.findByPost(post)).thenReturn(List.of()); + + service.deletePost(1L, "admin"); + + verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(), + isNull(), isNull(), eq(admin), isNull(), eq("T")); + } + @Test void createPostRespectsRateLimit() { PostRepository postRepo = mock(PostRepository.class); diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 4b89b6ec5..096d3b935 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -495,6 +495,24 @@ 已被管理员拒绝 + + + diff --git a/frontend_nuxt/pages/message-box/index.vue b/frontend_nuxt/pages/message-box/index.vue index 9de3b0c65..0a1578600 100644 --- a/frontend_nuxt/pages/message-box/index.vue +++ b/frontend_nuxt/pages/message-box/index.vue @@ -13,7 +13,7 @@
- +
Date: Sat, 23 Aug 2025 01:31:06 +0800 Subject: [PATCH 52/62] feat: add channel support --- .../openisle/config/ChannelInitializer.java | 32 +++ .../controller/ChannelController.java | 35 +++ .../controller/MessageController.java | 20 ++ .../java/com/openisle/dto/ChannelDto.java | 16 ++ .../openisle/dto/ConversationDetailDto.java | 3 + .../com/openisle/dto/ConversationDto.java | 3 + .../openisle/model/MessageConversation.java | 12 + .../MessageConversationRepository.java | 4 + .../com/openisle/service/ChannelService.java | 76 +++++++ .../com/openisle/service/MessageService.java | 54 ++++- frontend_nuxt/pages/message-box/[id].vue | 61 +++-- frontend_nuxt/pages/message-box/index.vue | 208 ++++++++++++++---- 12 files changed, 459 insertions(+), 65 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/service/ChannelService.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..a31feee70 --- /dev/null +++ b/backend/src/main/java/com/openisle/config/ChannelInitializer.java @@ -0,0 +1,32 @@ +package com.openisle.config; + +import com.openisle.model.MessageConversation; +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 MessageConversationRepository conversationRepository; + + @Override + public void run(String... args) { + if (conversationRepository.countByChannelTrue() == 0) { + MessageConversation chat = new MessageConversation(); + chat.setChannel(true); + chat.setName("吹水群"); + chat.setDescription("吹水聊天"); + chat.setAvatar("/default-avatar.svg"); + conversationRepository.save(chat); + + MessageConversation tech = new MessageConversation(); + tech.setChannel(true); + tech.setName("技术讨论群"); + tech.setDescription("讨论技术相关话题"); + tech.setAvatar("/default-avatar.svg"); + conversationRepository.save(tech); + } + } +} 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..69dcc8f97 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/ChannelController.java @@ -0,0 +1,35 @@ +package com.openisle.controller; + +import com.openisle.dto.ChannelDto; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.ChannelService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/channels") +@RequiredArgsConstructor +public class ChannelController { + private final ChannelService channelService; + private final UserRepository userRepository; + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return user.getId(); + } + + @GetMapping + public List listChannels(Authentication auth) { + return channelService.listChannels(getCurrentUserId(auth)); + } + + @PostMapping("/{channelId}/join") + public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) { + return channelService.joinChannel(channelId, getCurrentUserId(auth)); + } +} diff --git a/backend/src/main/java/com/openisle/controller/MessageController.java b/backend/src/main/java/com/openisle/controller/MessageController.java index c599fc785..bf2b2b1ad 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 ChannelMessageRequest 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 ChannelMessageRequest { + 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..9e1536600 --- /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 memberCount; + private boolean joined; + 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..6b0c9e97c 100644 --- a/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java +++ b/backend/src/main/java/com/openisle/dto/ConversationDetailDto.java @@ -8,6 +8,9 @@ import java.util.List; @Data public class ConversationDetailDto { private Long id; + private String name; + private boolean channel; + private String avatar; 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 index 17796f2c9..fdc83e639 100644 --- a/backend/src/main/java/com/openisle/dto/ConversationDto.java +++ b/backend/src/main/java/com/openisle/dto/ConversationDto.java @@ -10,6 +10,9 @@ import java.util.List; @Setter public class ConversationDto { private Long id; + private String name; + private boolean channel; + private String avatar; private MessageDto lastMessage; private List participants; private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/openisle/model/MessageConversation.java b/backend/src/main/java/com/openisle/model/MessageConversation.java index 9f9c94971..dfcda4e0c 100644 --- a/backend/src/main/java/com/openisle/model/MessageConversation.java +++ b/backend/src/main/java/com/openisle/model/MessageConversation.java @@ -20,6 +20,18 @@ public class MessageConversation { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // Indicates whether this conversation represents a public channel + @Column(nullable = false) + private boolean channel = false; + + // Channel metadata + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + private String avatar; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java index ef8bacb6c..492854bfc 100644 --- a/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java +++ b/backend/src/main/java/com/openisle/repository/MessageConversationRepository.java @@ -28,4 +28,8 @@ public interface MessageConversationRepository extends JpaRepository findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId); + + List findByChannelTrue(); + + long countByChannelTrue(); } \ No newline at end of file diff --git a/backend/src/main/java/com/openisle/service/ChannelService.java b/backend/src/main/java/com/openisle/service/ChannelService.java new file mode 100644 index 000000000..1ec1c9e25 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/ChannelService.java @@ -0,0 +1,76 @@ +package com.openisle.service; + +import com.openisle.dto.ChannelDto; +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 lombok.RequiredArgsConstructor; +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 +public class ChannelService { + private final MessageConversationRepository conversationRepository; + private final MessageParticipantRepository participantRepository; + private final MessageRepository messageRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listChannels(Long userId) { + List channels = conversationRepository.findByChannelTrue(); + return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList()); + } + + @Transactional + public ChannelDto joinChannel(Long channelId, Long userId) { + MessageConversation channel = conversationRepository.findById(channelId) + .orElseThrow(() -> new IllegalArgumentException("Channel not found")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + participantRepository.findByConversationIdAndUserId(channelId, userId) + .orElseGet(() -> { + MessageParticipant p = new MessageParticipant(); + p.setConversation(channel); + p.setUser(user); + MessageParticipant saved = participantRepository.save(p); + channel.getParticipants().add(saved); + return saved; + }); + return toDto(channel, userId); + } + + private ChannelDto toDto(MessageConversation channel, Long userId) { + ChannelDto dto = new ChannelDto(); + dto.setId(channel.getId()); + dto.setName(channel.getName()); + dto.setDescription(channel.getDescription()); + dto.setAvatar(channel.getAvatar()); + dto.setMemberCount(channel.getParticipants().size()); + boolean joined = channel.getParticipants().stream() + .anyMatch(p -> p.getUser().getId().equals(userId)); + dto.setJoined(joined); + if (joined) { + MessageParticipant participant = channel.getParticipants().stream() + .filter(p -> p.getUser().getId().equals(userId)) + .findFirst().orElse(null); + LocalDateTime lastRead = participant.getLastReadAt() == null + ? LocalDateTime.of(1970, 1, 1, 0, 0) + : participant.getLastReadAt(); + long unread = messageRepository + .countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId); + dto.setUnreadCount(unread); + } else { + dto.setUnreadCount(0); + } + return dto; + } +} diff --git a/backend/src/main/java/com/openisle/service/MessageService.java b/backend/src/main/java/com/openisle/service/MessageService.java index 3e668742e..7f28d4809 100644 --- a/backend/src/main/java/com/openisle/service/MessageService.java +++ b/backend/src/main/java/com/openisle/service/MessageService.java @@ -82,6 +82,49 @@ 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")); + + // Join the conversation if not already a participant (useful for channels) + participantRepository.findByConversationIdAndUserId(conversationId, senderId) + .orElseGet(() -> { + MessageParticipant p = new MessageParticipant(); + p.setConversation(conversation); + p.setUser(sender); + return participantRepository.save(p); + }); + + 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); + + // Notify all participants except sender for updates + for (MessageParticipant participant : conversation.getParticipants()) { + if (participant.getUser().getId().equals(senderId)) continue; + String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages"; + messagingTemplate.convertAndSend(userDestination, messageDto); + + long unreadCount = getUnreadMessageCount(participant.getUser().getId()); + String username = participant.getUser().getUsername(); + messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount); + } + + return message; + } + private MessageDto toDto(Message message) { MessageDto dto = new MessageDto(); dto.setId(message.getId()); @@ -134,12 +177,18 @@ public class MessageService { @Transactional(readOnly = true) public List getConversations(Long userId) { List conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId); - return conversations.stream().map(c -> toDto(c, userId)).collect(Collectors.toList()); + return conversations.stream() + .filter(c -> !c.isChannel()) + .map(c -> toDto(c, userId)) + .collect(Collectors.toList()); } private ConversationDto toDto(MessageConversation conversation, Long userId) { ConversationDto dto = new ConversationDto(); dto.setId(conversation.getId()); + dto.setChannel(conversation.isChannel()); + dto.setName(conversation.getName()); + dto.setAvatar(conversation.getAvatar()); dto.setCreatedAt(conversation.getCreatedAt()); if (conversation.getLastMessage() != null) { dto.setLastMessage(toDto(conversation.getLastMessage())); @@ -189,6 +238,9 @@ public class MessageService { ConversationDetailDto detailDto = new ConversationDetailDto(); detailDto.setId(conversation.getId()); + detailDto.setName(conversation.getName()); + detailDto.setChannel(conversation.isChannel()); + detailDto.setAvatar(conversation.getAvatar()); detailDto.setParticipants(participants); detailDto.setMessages(messageDtoPage); diff --git a/frontend_nuxt/pages/message-box/[id].vue b/frontend_nuxt/pages/message-box/[id].vue index 737310efc..a33d6eb19 100644 --- a/frontend_nuxt/pages/message-box/[id].vue +++ b/frontend_nuxt/pages/message-box/[id].vue @@ -1,10 +1,12 @@