mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 13:30:55 +08:00
Compare commits
10 Commits
feature/ag
...
codex/bugf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c890d034 | ||
|
|
8d20d8ef67 | ||
|
|
91b393a00c | ||
|
|
658859a05a | ||
|
|
76dd57b858 | ||
|
|
18edde64c3 | ||
|
|
ebc79f36e7 | ||
|
|
a7fbd1eb75 | ||
|
|
f773d17748 | ||
|
|
15d36709c3 |
10
AGENTS.md
10
AGENTS.md
@@ -66,3 +66,13 @@
|
|||||||
- 不提交 `.env`、密钥、生产 token。
|
- 不提交 `.env`、密钥、生产 token。
|
||||||
- 不在未明确授权下执行破坏性命令(如大范围删除、强制重置)。
|
- 不在未明确授权下执行破坏性命令(如大范围删除、强制重置)。
|
||||||
- 不在无关文件中进行格式化/重排以“顺手优化”。
|
- 不在无关文件中进行格式化/重排以“顺手优化”。
|
||||||
|
|
||||||
|
## 9) Agent 开发规范
|
||||||
|
|
||||||
|
- 开发前需先 `checkout` 新分支,并在该分支完成提交。
|
||||||
|
- 分支命名格式:
|
||||||
|
- 新功能:`feature/<简要描述>`
|
||||||
|
- 缺陷修复:`bugfix/<简要描述>`
|
||||||
|
- 提交信息格式:
|
||||||
|
- 新功能:`feat: <简要描述>`
|
||||||
|
- 缺陷修复:`bugfix: <简要描述>`
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.openisle.service;
|
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.Comment;
|
||||||
import com.openisle.model.Message;
|
import com.openisle.model.Message;
|
||||||
import com.openisle.model.NotificationType;
|
import com.openisle.model.NotificationType;
|
||||||
@@ -12,15 +15,17 @@ import com.openisle.repository.MessageRepository;
|
|||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import com.openisle.service.EmailSender;
|
import java.util.HashMap;
|
||||||
import com.openisle.service.NotificationService;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class ReactionService {
|
public class ReactionService {
|
||||||
|
|
||||||
private final ReactionRepository reactionRepository;
|
private final ReactionRepository reactionRepository;
|
||||||
@@ -29,6 +34,8 @@ public class ReactionService {
|
|||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final NotificationProducer notificationProducer;
|
||||||
|
private final ReactionMapper reactionMapper;
|
||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
|
|
||||||
@Value("${app.website-url}")
|
@Value("${app.website-url}")
|
||||||
@@ -124,18 +131,45 @@ public class ReactionService {
|
|||||||
message,
|
message,
|
||||||
type
|
type
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Map<String, Object> syncPayload = new HashMap<>();
|
||||||
|
syncPayload.put("eventType", "MESSAGE_REACTION");
|
||||||
|
syncPayload.put("conversationId", message.getConversation().getId());
|
||||||
|
syncPayload.put("messageId", message.getId());
|
||||||
|
|
||||||
if (existing.isPresent()) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Reaction reaction = new Reaction();
|
Reaction reaction = new Reaction();
|
||||||
reaction.setUser(user);
|
reaction.setUser(user);
|
||||||
reaction.setMessage(message);
|
reaction.setMessage(message);
|
||||||
reaction.setType(type);
|
reaction.setType(type);
|
||||||
reaction = reactionRepository.save(reaction);
|
reaction = reactionRepository.save(reaction);
|
||||||
|
|
||||||
|
syncPayload.put("action", "ADDED");
|
||||||
|
syncPayload.put("reaction", reactionMapper.toDto(reaction));
|
||||||
|
sendMessageReactionSync(user.getUsername(), syncPayload);
|
||||||
|
|
||||||
return reaction;
|
return reaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendMessageReactionSync(String shardUsername, Map<String, Object> 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<Reaction> getReactionsForPost(Long postId) {
|
public java.util.List<Reaction> getReactionsForPost(Long postId) {
|
||||||
Post post = postRepository
|
Post post = postRepository
|
||||||
.findById(postId)
|
.findById(postId)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.openisle.model.Reaction;
|
|||||||
import com.openisle.model.ReactionType;
|
import com.openisle.model.ReactionType;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.service.LevelService;
|
import com.openisle.service.LevelService;
|
||||||
|
import com.openisle.service.PointService;
|
||||||
import com.openisle.service.ReactionService;
|
import com.openisle.service.ReactionService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
@@ -39,6 +40,9 @@ class ReactionControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private LevelService levelService;
|
private LevelService levelService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PointService pointService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reactToPost() throws Exception {
|
void reactToPost() throws Exception {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
package com.openisle.service;
|
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 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.model.*;
|
||||||
import com.openisle.repository.*;
|
import com.openisle.repository.*;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
class ReactionServiceTest {
|
class ReactionServiceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reactToPostSendsEmailEveryFive() {
|
void reactToPostCreatesNotificationForAuthor() {
|
||||||
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||||
UserRepository userRepo = mock(UserRepository.class);
|
UserRepository userRepo = mock(UserRepository.class);
|
||||||
PostRepository postRepo = mock(PostRepository.class);
|
PostRepository postRepo = mock(PostRepository.class);
|
||||||
CommentRepository commentRepo = mock(CommentRepository.class);
|
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||||
MessageRepository messageRepo = mock(MessageRepository.class);
|
MessageRepository messageRepo = mock(MessageRepository.class);
|
||||||
NotificationService notif = mock(NotificationService.class);
|
NotificationService notif = mock(NotificationService.class);
|
||||||
|
NotificationProducer notificationProducer = mock(NotificationProducer.class);
|
||||||
|
ReactionMapper reactionMapper = new ReactionMapper();
|
||||||
EmailSender email = mock(EmailSender.class);
|
EmailSender email = mock(EmailSender.class);
|
||||||
ReactionService service = new ReactionService(
|
ReactionService service = new ReactionService(
|
||||||
reactionRepo,
|
reactionRepo,
|
||||||
@@ -25,14 +35,10 @@ class ReactionServiceTest {
|
|||||||
commentRepo,
|
commentRepo,
|
||||||
messageRepo,
|
messageRepo,
|
||||||
notif,
|
notif,
|
||||||
|
notificationProducer,
|
||||||
|
reactionMapper,
|
||||||
email
|
email
|
||||||
);
|
);
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(
|
|
||||||
service,
|
|
||||||
"websiteUrl",
|
|
||||||
"https://ex.com"
|
|
||||||
);
|
|
||||||
|
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setId(1L);
|
user.setId(1L);
|
||||||
user.setUsername("bob");
|
user.setUsername("bob");
|
||||||
@@ -49,11 +55,162 @@ class ReactionServiceTest {
|
|||||||
Optional.empty()
|
Optional.empty()
|
||||||
);
|
);
|
||||||
when(reactionRepo.save(any(Reaction.class))).thenAnswer(i -> i.getArgument(0));
|
when(reactionRepo.save(any(Reaction.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
when(reactionRepo.countReceived(author.getUsername())).thenReturn(5L);
|
|
||||||
|
|
||||||
service.reactToPost("bob", 3L, ReactionType.LIKE);
|
service.reactToPost("bob", 3L, ReactionType.LIKE);
|
||||||
|
|
||||||
verify(email).sendEmail("a@a.com", "你有新的互动", "https://ex.com/messages");
|
verify(notif).createNotification(
|
||||||
verify(notif).sendCustomPush(author, "你有新的互动", "https://ex.com/messages");
|
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<MessageNotificationPayload> 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<MessageNotificationPayload> 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ bun dev
|
|||||||
|
|
||||||
- `frontend/` 前端技术文档
|
- `frontend/` 前端技术文档
|
||||||
- `backend/` 后端技术文档
|
- `backend/` 后端技术文档
|
||||||
|
- `deployment/` 预发/生产部署文档
|
||||||
- `openapi/` 后端 API 文档
|
- `openapi/` 后端 API 文档
|
||||||
|
|||||||
147
docs/content/docs/deployment/index.mdx
Normal file
147
docs/content/docs/deployment/index.mdx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
title: 部署指南
|
||||||
|
description: OpenIsle 预发与生产环境部署说明
|
||||||
|
---
|
||||||
|
|
||||||
|
# 部署指南
|
||||||
|
|
||||||
|
本页覆盖 OpenIsle 当前仓库内已有的部署链路:`deploy/` 脚本 + GitHub Actions 工作流 + Docker Compose。
|
||||||
|
|
||||||
|
## 部署总览
|
||||||
|
|
||||||
|
| 环境 | 触发方式 | 脚本 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 预发 (staging) | `main` 分支 push / 手动触发 | `deploy/deploy_staging.sh` |
|
||||||
|
| 生产 (prod) | 每日定时 / 手动触发 | `deploy/deploy.sh` |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 两套工作流共用并发锁 `openisle-server`,避免同一台服务器并发部署冲突。
|
||||||
|
- 两个脚本都会执行 `git checkout -B <branch> origin/<branch>` + `git reset --hard origin/<branch>`,确保服务器代码与远端分支对齐。
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
1. 服务器已安装:`git`、`docker`、`docker compose`(插件版本)。
|
||||||
|
2. 服务器目录与脚本保持一致:
|
||||||
|
- 生产仓库路径:`/opt/openisle/OpenIsle`
|
||||||
|
- 预发仓库路径:`/opt/openisle/OpenIsle-staging`
|
||||||
|
3. 两个仓库目录下都已创建 `.env`(基于根目录 `.env.example`)。
|
||||||
|
4. 已配置反向代理(参考 `nginx/openisle` 与 `nginx/openisle-staging`)。
|
||||||
|
|
||||||
|
## 环境变量准备
|
||||||
|
|
||||||
|
先复制模板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
至少确认这些变量:
|
||||||
|
|
||||||
|
- 安全相关:`JWT_SECRET`、`JWT_REASON_SECRET`、`JWT_RESET_SECRET`、`JWT_INVITE_SECRET`
|
||||||
|
- 存储与队列:`MYSQL_*`、`REDIS_*`、`RABBITMQ_*`
|
||||||
|
- 站点与前端:`WEBSITE_URL`、`NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL`、`NUXT_PUBLIC_WEBSITE_BASE_URL`
|
||||||
|
|
||||||
|
如果同机同时跑“生产 + 预发”,预发端口必须改开,避免冲突。根据当前 Nginx 示例,预发可使用:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SERVER_PORT=8081
|
||||||
|
FRONTEND_PORT=3001
|
||||||
|
WEBSOCKET_PORT=8083
|
||||||
|
OPENISLE_MCP_PORT=8086
|
||||||
|
WEBSITE_URL=https://staging.open-isle.com
|
||||||
|
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||||
|
NUXT_PUBLIC_WEBSOCKET_URL=https://staging.open-isle.com/websocket
|
||||||
|
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 手动部署
|
||||||
|
|
||||||
|
在服务器执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生产(默认 main)
|
||||||
|
bash /opt/openisle/OpenIsle/deploy/deploy.sh
|
||||||
|
|
||||||
|
# 预发(默认 main)
|
||||||
|
bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
部署指定分支:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/openisle/OpenIsle/deploy/deploy.sh feature/xxx
|
||||||
|
bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh feature/xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动完成:
|
||||||
|
|
||||||
|
1. 拉取并重置代码到目标分支最新提交
|
||||||
|
2. `docker compose config` 校验
|
||||||
|
3. 拉取基础镜像 + 构建 `frontend_service`、`mcp`
|
||||||
|
4. 重建并启动关键服务(`mysql`、`redis`、`rabbitmq`、`websocket-service`、`springboot`、`frontend_service`、`mcp`)
|
||||||
|
|
||||||
|
## CI/CD 触发规则
|
||||||
|
|
||||||
|
- 预发:`.github/workflows/deploy-staging.yml`
|
||||||
|
- `main` 分支 push 自动触发
|
||||||
|
- 支持 `workflow_dispatch` 手动触发
|
||||||
|
- 生产:`.github/workflows/deploy.yml`
|
||||||
|
- 每天 `UTC 19:00` 定时触发(北京时间次日 `03:00`)
|
||||||
|
- 支持 `workflow_dispatch` 手动触发
|
||||||
|
- 文档站:`.github/workflows/deploy-docs.yml`
|
||||||
|
- 在预发部署成功后触发,发布到 `gh-pages`
|
||||||
|
|
||||||
|
## 部署后检查
|
||||||
|
|
||||||
|
查看容器状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f /opt/openisle/OpenIsle/docker/docker-compose.yaml --env-file /opt/openisle/OpenIsle/.env ps
|
||||||
|
docker compose -f /opt/openisle/OpenIsle-staging/docker/docker-compose.yaml --env-file /opt/openisle/OpenIsle-staging/.env ps
|
||||||
|
```
|
||||||
|
|
||||||
|
查看核心服务日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f /opt/openisle/OpenIsle/docker/docker-compose.yaml --env-file /opt/openisle/OpenIsle/.env logs -f springboot websocket-service frontend_service
|
||||||
|
```
|
||||||
|
|
||||||
|
本机健康检查(自动读取 `.env` 端口):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ENV_FILE=/opt/openisle/OpenIsle/.env
|
||||||
|
SERVER_PORT=$(grep '^SERVER_PORT=' "$ENV_FILE" | cut -d= -f2)
|
||||||
|
WS_PORT=$(grep '^WEBSOCKET_PORT=' "$ENV_FILE" | cut -d= -f2)
|
||||||
|
curl -fsS "http://127.0.0.1:${SERVER_PORT}/actuator/health"
|
||||||
|
curl -fsS "http://127.0.0.1:${WS_PORT}/actuator/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 回滚建议
|
||||||
|
|
||||||
|
由于部署脚本总是对齐远端分支最新提交,回滚建议走“可追溯分支”:
|
||||||
|
|
||||||
|
1. 在本地创建回滚分支并推送:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b rollback/2026-02-11 <稳定提交SHA>
|
||||||
|
git push origin rollback/2026-02-11
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 在服务器按分支重新部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/openisle/OpenIsle/deploy/deploy.sh rollback/2026-02-11
|
||||||
|
```
|
||||||
|
|
||||||
|
同理可用于预发:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh rollback/2026-02-11
|
||||||
|
```
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
- 脚本使用 `up -d --force-recreate --remove-orphans`,目标服务会被重建,部署窗口内可能出现短时连接中断。
|
||||||
|
- `.env` 缺失时脚本会直接退出,不会继续部署。
|
||||||
|
- 生产与预发共机时,务必避免端口冲突并保持 Nginx upstream 端口一致。
|
||||||
3
docs/content/docs/deployment/meta.json
Normal file
3
docs/content/docs/deployment/meta.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"root": true
|
||||||
|
}
|
||||||
@@ -11,4 +11,5 @@ OpenIsle 是一个现代化的社区平台,提供完整的社交功能。
|
|||||||
|
|
||||||
- [后端开发指南](/backend) - 了解后端架构和开发
|
- [后端开发指南](/backend) - 了解后端架构和开发
|
||||||
- [前端开发指南](/frontend) - 了解前端技术栈和组件
|
- [前端开发指南](/frontend) - 了解前端技术栈和组件
|
||||||
|
- [部署指南](/deployment) - 了解预发/生产部署流程与回滚方法
|
||||||
- [API 文档](/openapi) - 查看完整的 API 接口文档
|
- [API 文档](/openapi) - 查看完整的 API 接口文档
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"pages": ["index", "frontend", "backend", "openapi"]
|
"pages": ["index", "frontend", "backend", "deployment", "openapi"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const props = defineProps({
|
|||||||
const gotoCategory = async () => {
|
const gotoCategory = async () => {
|
||||||
if (!props.category) return
|
if (!props.category) return
|
||||||
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||||
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
await navigateTo({ path: '/', query: { category: value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
const isImageIcon = (icon) => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ defineProps({
|
|||||||
|
|
||||||
const gotoTag = async (tag) => {
|
const gotoTag = async (tag) => {
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
await navigateTo({ path: '/', query: { tags: value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
const isImageIcon = (icon) => {
|
||||||
|
|||||||
@@ -357,13 +357,13 @@ const isImageIcon = (icon) => {
|
|||||||
|
|
||||||
const gotoCategory = (c) => {
|
const gotoCategory = (c) => {
|
||||||
const value = encodeURIComponent(c.id ?? c.name)
|
const value = encodeURIComponent(c.id ?? c.name)
|
||||||
navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
navigateTo({ path: '/', query: { category: value } })
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
const gotoTag = (t) => {
|
const gotoTag = (t) => {
|
||||||
const value = encodeURIComponent(t.id ?? t.name)
|
const value = encodeURIComponent(t.id ?? t.name)
|
||||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
navigateTo({ path: '/', query: { tags: value } })
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ watch(selected, (val) => {
|
|||||||
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
|
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
|
||||||
}
|
}
|
||||||
} else if (opt.type === 'category') {
|
} else if (opt.type === 'category') {
|
||||||
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
|
navigateTo({ path: '/', query: { category: opt.id } })
|
||||||
} else if (opt.type === 'tag') {
|
} else if (opt.type === 'tag') {
|
||||||
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
|
navigateTo({ path: '/', query: { tags: opt.id } })
|
||||||
}
|
}
|
||||||
selected.value = null
|
selected.value = null
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
|
|||||||
@@ -202,6 +202,28 @@ const selectedTagsSet = (tags) => {
|
|||||||
.map((v) => (isNaN(v) ? v : Number(v)))
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeCategoryFromQuery = (category) => {
|
||||||
|
if (category == null || category === '') return ''
|
||||||
|
const raw = Array.isArray(category) ? category[0] : category
|
||||||
|
const decoded = decodeURIComponent(raw)
|
||||||
|
return isNaN(decoded) ? decoded : Number(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTagsFromQuery = (tags) => {
|
||||||
|
if (tags == null || tags === '') return []
|
||||||
|
const raw = Array.isArray(tags) ? tags.join(',') : tags
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.filter((v) => v)
|
||||||
|
.map((v) => decodeURIComponent(v))
|
||||||
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const arraysShallowEqual = (a = [], b = []) => {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((v, idx) => String(v) === String(b[idx]))
|
||||||
|
}
|
||||||
|
|
||||||
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const { category, tags } = route.query
|
const { category, tags } = route.query
|
||||||
@@ -239,6 +261,32 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 从筛选器变更回写到 URL,确保浏览器历史可回退到上一个筛选状态。
|
||||||
|
watch([selectedCategory, selectedTags], async ([category, tags]) => {
|
||||||
|
const routeCategory = normalizeCategoryFromQuery(route.query.category)
|
||||||
|
const routeTags = normalizeTagsFromQuery(route.query.tags)
|
||||||
|
|
||||||
|
const categoryChanged = String(category ?? '') !== String(routeCategory ?? '')
|
||||||
|
const tagsChanged = !arraysShallowEqual(tags || [], routeTags)
|
||||||
|
if (!categoryChanged && !tagsChanged) return
|
||||||
|
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
|
||||||
|
if (category == null || category === '') {
|
||||||
|
delete nextQuery.category
|
||||||
|
} else {
|
||||||
|
nextQuery.category = encodeURIComponent(String(category))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) {
|
||||||
|
delete nextQuery.tags
|
||||||
|
} else {
|
||||||
|
nextQuery.tags = tags.map((v) => encodeURIComponent(String(v))).join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateTo({ path: '/', query: nextQuery })
|
||||||
|
})
|
||||||
|
|
||||||
/** 选项加载(分类/标签名称回填) **/
|
/** 选项加载(分类/标签名称回填) **/
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
|
|||||||
@@ -447,6 +447,11 @@ const subscribeToConversation = () => {
|
|||||||
try {
|
try {
|
||||||
const parsedMessage = JSON.parse(message.body)
|
const parsedMessage = JSON.parse(message.body)
|
||||||
|
|
||||||
|
if (parsedMessage?.eventType === 'MESSAGE_REACTION') {
|
||||||
|
applyMessageReactionSync(parsedMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (parsedMessage.sender && parsedMessage.sender.id === currentUser.value.id) {
|
if (parsedMessage.sender && parsedMessage.sender.id === currentUser.value.id) {
|
||||||
return
|
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) => {
|
watch(isConnected, (newValue) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
subscribeToConversation()
|
subscribeToConversation()
|
||||||
|
|||||||
@@ -643,7 +643,7 @@ const sendMessage = async () => {
|
|||||||
|
|
||||||
const gotoTag = (tag) => {
|
const gotoTag = (tag) => {
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
navigateTo({ path: '/', query: { tags: value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
|||||||
@@ -82,6 +82,56 @@ export async function markNotificationsRead(ids) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MARK_ALL_FETCH_SIZE = 100
|
||||||
|
const MARK_ALL_CHUNK_SIZE = 200
|
||||||
|
const MARK_ALL_MAX_PAGES = 200
|
||||||
|
|
||||||
|
async function fetchUnreadNotificationsPage(page, size) {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) throw new Error('NO_TOKEN')
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/notifications/unread?page=${page}&size=${size}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('FETCH_UNREAD_FAILED')
|
||||||
|
const data = await res.json()
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUnreadNotificationIds(excludedTypes = []) {
|
||||||
|
const excludedTypeSet = new Set(excludedTypes)
|
||||||
|
const ids = []
|
||||||
|
|
||||||
|
for (let page = 0; page < MARK_ALL_MAX_PAGES; page++) {
|
||||||
|
const pageData = await fetchUnreadNotificationsPage(page, MARK_ALL_FETCH_SIZE)
|
||||||
|
if (pageData.length === 0) break
|
||||||
|
|
||||||
|
for (const notification of pageData) {
|
||||||
|
if (!notification || excludedTypeSet.has(notification.type)) continue
|
||||||
|
if (typeof notification.id !== 'number') continue
|
||||||
|
ids.push(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageData.length < MARK_ALL_FETCH_SIZE) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(ids)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markNotificationsReadInChunks(ids) {
|
||||||
|
for (let i = 0; i < ids.length; i += MARK_ALL_CHUNK_SIZE) {
|
||||||
|
const chunk = ids.slice(i, i + MARK_ALL_CHUNK_SIZE)
|
||||||
|
const ok = await markNotificationsRead(chunk)
|
||||||
|
if (!ok) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchNotificationPreferences() {
|
export async function fetchNotificationPreferences() {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -390,29 +440,37 @@ function createFetchNotifications() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
// 除了 REGISTER_REQUEST 类型消息
|
// 为了覆盖分页中的全部未读,先从后端分页拉取全部未读 ID(排除 REGISTER_REQUEST)。
|
||||||
const idsToMark = notifications.value
|
const localIdsToMark = notifications.value
|
||||||
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
||||||
.map((n) => n.id)
|
.map((n) => n.id)
|
||||||
if (idsToMark.length === 0) return
|
|
||||||
notifications.value.forEach((n) => {
|
notifications.value.forEach((n) => {
|
||||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
||||||
})
|
})
|
||||||
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
||||||
const ok = await markNotificationsRead(idsToMark)
|
|
||||||
if (!ok) {
|
try {
|
||||||
|
const idsToMark = await collectUnreadNotificationIds(['REGISTER_REQUEST'])
|
||||||
|
if (idsToMark.length > 0) {
|
||||||
|
const ok = await markNotificationsReadInChunks(idsToMark)
|
||||||
|
if (!ok) throw new Error('MARK_READ_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchUnreadCount()
|
||||||
|
if (authState.role === 'ADMIN') {
|
||||||
|
toast.success('已读所有消息(注册请求除外)')
|
||||||
|
} else {
|
||||||
|
toast.success('已读所有消息')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
notifications.value.forEach((n) => {
|
notifications.value.forEach((n) => {
|
||||||
if (idsToMark.includes(n.id)) n.read = false
|
if (localIdsToMark.includes(n.id)) n.read = false
|
||||||
})
|
})
|
||||||
await fetchUnreadCount()
|
await fetchUnreadCount()
|
||||||
|
toast.error('已读操作失败,请稍后重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fetchUnreadCount()
|
|
||||||
if (authState.role === 'ADMIN') {
|
|
||||||
toast.success('已读所有消息(注册请求除外)')
|
|
||||||
} else {
|
|
||||||
toast.success('已读所有消息')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
|||||||
@@ -55,8 +55,19 @@ public class NotificationListener {
|
|||||||
if (payloadObject instanceof Map) {
|
if (payloadObject instanceof Map) {
|
||||||
Map<String, Object> payloadMap = (Map<String, Object>) payloadObject;
|
Map<String, Object> payloadMap = (Map<String, Object>) 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发送逻辑
|
// 处理包含完整对话信息的消息 - 完全复制之前的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");
|
Object messageObj = payloadMap.get("message");
|
||||||
Map<String, Object> conversationInfo = (Map<String, Object>) payloadMap.get("conversation");
|
Map<String, Object> conversationInfo = (Map<String, Object>) payloadMap.get("conversation");
|
||||||
Long conversationId = ((Number) conversationInfo.get("id")).longValue();
|
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);
|
log.error("Failed to process and send message for user {}", username, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user