Compare commits

..

5 Commits

Author SHA1 Message Date
tim
c9c890d034 bugfix: sync message reactions via websocket 2026-02-11 20:33:41 +08:00
Tim
8d20d8ef67 Merge pull request #1141 from nagisa77/codex/feature-deployment-docs
feat: 增加部署文档与导航入口
2026-02-11 20:10:29 +08:00
tim
91b393a00c feat: 增加部署文档与导航入口 2026-02-11 19:58:59 +08:00
tim
658859a05a feat: AGENTS.md update 2026-02-11 19:52:53 +08:00
Tim
76dd57b858 Merge pull request #1139 from nagisa77/feature/fix-category-back-history
Feature/fix category back history
2026-02-06 17:44:55 +08:00
11 changed files with 419 additions and 16 deletions

View File

@@ -66,3 +66,13 @@
- 不提交 `.env`、密钥、生产 token。
- 不在未明确授权下执行破坏性命令(如大范围删除、强制重置)。
- 不在无关文件中进行格式化/重排以“顺手优化”。
## 9) Agent 开发规范
- 开发前需先 `checkout` 新分支,并在该分支完成提交。
- 分支命名格式:
- 新功能:`feature/<简要描述>`
- 缺陷修复:`bugfix/<简要描述>`
- 提交信息格式:
- 新功能:`feat: <简要描述>`
- 缺陷修复:`bugfix: <简要描述>`

View File

@@ -1,5 +1,8 @@
package com.openisle.service;
import com.openisle.dto.MessageNotificationPayload;
import com.openisle.dto.ReactionDto;
import com.openisle.mapper.ReactionMapper;
import com.openisle.model.Comment;
import com.openisle.model.Message;
import com.openisle.model.NotificationType;
@@ -12,15 +15,17 @@ import com.openisle.repository.MessageRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.service.EmailSender;
import com.openisle.service.NotificationService;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
public class ReactionService {
private final ReactionRepository reactionRepository;
@@ -29,6 +34,8 @@ public class ReactionService {
private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final NotificationService notificationService;
private final NotificationProducer notificationProducer;
private final ReactionMapper reactionMapper;
private final EmailSender emailSender;
@Value("${app.website-url}")
@@ -124,18 +131,45 @@ public class ReactionService {
message,
type
);
Map<String, Object> syncPayload = new HashMap<>();
syncPayload.put("eventType", "MESSAGE_REACTION");
syncPayload.put("conversationId", message.getConversation().getId());
syncPayload.put("messageId", message.getId());
if (existing.isPresent()) {
reactionRepository.delete(existing.get());
Reaction removed = existing.get();
ReactionDto removedDto = reactionMapper.toDto(removed);
reactionRepository.delete(removed);
syncPayload.put("action", "REMOVED");
syncPayload.put("reaction", removedDto);
sendMessageReactionSync(user.getUsername(), syncPayload);
return null;
}
Reaction reaction = new Reaction();
reaction.setUser(user);
reaction.setMessage(message);
reaction.setType(type);
reaction = reactionRepository.save(reaction);
syncPayload.put("action", "ADDED");
syncPayload.put("reaction", reactionMapper.toDto(reaction));
sendMessageReactionSync(user.getUsername(), syncPayload);
return reaction;
}
private void sendMessageReactionSync(String shardUsername, Map<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) {
Post post = postRepository
.findById(postId)

View File

@@ -14,6 +14,7 @@ import com.openisle.model.Reaction;
import com.openisle.model.ReactionType;
import com.openisle.model.User;
import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import com.openisle.service.ReactionService;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -39,6 +40,9 @@ class ReactionControllerTest {
@MockBean
private LevelService levelService;
@MockBean
private PointService pointService;
@Test
void reactToPost() throws Exception {
User user = new User();

View File

@@ -1,22 +1,32 @@
package com.openisle.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.*;
import com.openisle.dto.MessageNotificationPayload;
import com.openisle.dto.ReactionDto;
import com.openisle.mapper.ReactionMapper;
import com.openisle.model.*;
import com.openisle.repository.*;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class ReactionServiceTest {
@Test
void reactToPostSendsEmailEveryFive() {
void reactToPostCreatesNotificationForAuthor() {
ReactionRepository reactionRepo = mock(ReactionRepository.class);
UserRepository userRepo = mock(UserRepository.class);
PostRepository postRepo = mock(PostRepository.class);
CommentRepository commentRepo = mock(CommentRepository.class);
MessageRepository messageRepo = mock(MessageRepository.class);
NotificationService notif = mock(NotificationService.class);
NotificationProducer notificationProducer = mock(NotificationProducer.class);
ReactionMapper reactionMapper = new ReactionMapper();
EmailSender email = mock(EmailSender.class);
ReactionService service = new ReactionService(
reactionRepo,
@@ -25,14 +35,10 @@ class ReactionServiceTest {
commentRepo,
messageRepo,
notif,
notificationProducer,
reactionMapper,
email
);
org.springframework.test.util.ReflectionTestUtils.setField(
service,
"websiteUrl",
"https://ex.com"
);
User user = new User();
user.setId(1L);
user.setUsername("bob");
@@ -49,11 +55,162 @@ class ReactionServiceTest {
Optional.empty()
);
when(reactionRepo.save(any(Reaction.class))).thenAnswer(i -> i.getArgument(0));
when(reactionRepo.countReceived(author.getUsername())).thenReturn(5L);
service.reactToPost("bob", 3L, ReactionType.LIKE);
verify(email).sendEmail("a@a.com", "你有新的互动", "https://ex.com/messages");
verify(notif).sendCustomPush(author, "你有新的互动", "https://ex.com/messages");
verify(notif).createNotification(
eq(author),
eq(NotificationType.REACTION),
eq(post),
isNull(),
isNull(),
eq(user),
eq(ReactionType.LIKE),
isNull()
);
verifyNoInteractions(email);
}
@Test
void reactToMessageBroadcastsAddedEvent() {
ReactionRepository reactionRepo = mock(ReactionRepository.class);
UserRepository userRepo = mock(UserRepository.class);
PostRepository postRepo = mock(PostRepository.class);
CommentRepository commentRepo = mock(CommentRepository.class);
MessageRepository messageRepo = mock(MessageRepository.class);
NotificationService notif = mock(NotificationService.class);
NotificationProducer notificationProducer = mock(NotificationProducer.class);
ReactionMapper reactionMapper = new ReactionMapper();
EmailSender email = mock(EmailSender.class);
ReactionService service = new ReactionService(
reactionRepo,
userRepo,
postRepo,
commentRepo,
messageRepo,
notif,
notificationProducer,
reactionMapper,
email
);
User user = new User();
user.setId(10L);
user.setUsername("alice");
MessageConversation conversation = new MessageConversation();
conversation.setId(20L);
Message message = new Message();
message.setId(30L);
message.setConversation(conversation);
when(userRepo.findByUsername("alice")).thenReturn(Optional.of(user));
when(messageRepo.findById(30L)).thenReturn(Optional.of(message));
when(reactionRepo.findByUserAndMessageAndType(user, message, ReactionType.LIKE)).thenReturn(
Optional.empty()
);
when(reactionRepo.save(any(Reaction.class))).thenAnswer(invocation -> {
Reaction saved = invocation.getArgument(0);
saved.setId(40L);
return saved;
});
Reaction result = service.reactToMessage("alice", 30L, ReactionType.LIKE);
assertEquals(40L, result.getId());
ArgumentCaptor<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());
}
}

View File

@@ -19,4 +19,5 @@ bun dev
- `frontend/` 前端技术文档
- `backend/` 后端技术文档
- `deployment/` 预发/生产部署文档
- `openapi/` 后端 API 文档

View 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 端口一致。

View File

@@ -0,0 +1,3 @@
{
"root": true
}

View File

@@ -11,4 +11,5 @@ OpenIsle 是一个现代化的社区平台,提供完整的社交功能。
- [后端开发指南](/backend) - 了解后端架构和开发
- [前端开发指南](/frontend) - 了解前端技术栈和组件
- [部署指南](/deployment) - 了解预发/生产部署流程与回滚方法
- [API 文档](/openapi) - 查看完整的 API 接口文档

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "frontend", "backend", "openapi"]
"pages": ["index", "frontend", "backend", "deployment", "openapi"]
}

View File

@@ -447,6 +447,11 @@ const subscribeToConversation = () => {
try {
const parsedMessage = JSON.parse(message.body)
if (parsedMessage?.eventType === 'MESSAGE_REACTION') {
applyMessageReactionSync(parsedMessage)
return
}
if (parsedMessage.sender && parsedMessage.sender.id === currentUser.value.id) {
return
}
@@ -472,6 +477,36 @@ const subscribeToConversation = () => {
})
}
function applyMessageReactionSync(event) {
const targetMessageId = Number(event?.messageId)
if (!Number.isFinite(targetMessageId)) return
const targetMessage = messages.value.find((msg) => Number(msg.id) === targetMessageId)
if (!targetMessage) return
if (!Array.isArray(targetMessage.reactions)) {
targetMessage.reactions = []
}
const reaction = event?.reaction
if (!reaction?.type || !reaction?.user) return
const sameReaction = (current) => current?.type === reaction.type && current?.user === reaction.user
if (event.action === 'REMOVED') {
targetMessage.reactions = targetMessage.reactions.filter((current) => !sameReaction(current))
return
}
if (event.action === 'ADDED') {
const existingIndex = targetMessage.reactions.findIndex((current) => sameReaction(current))
if (existingIndex > -1) {
targetMessage.reactions.splice(existingIndex, 1, reaction)
} else {
targetMessage.reactions.push(reaction)
}
}
}
watch(isConnected, (newValue) => {
if (newValue) {
subscribeToConversation()

View File

@@ -55,8 +55,19 @@ public class NotificationListener {
if (payloadObject instanceof Map) {
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发送逻辑
if (payloadMap.containsKey("message") && payloadMap.containsKey("conversation") && payloadMap.containsKey("senderId")) {
else if (payloadMap.containsKey("message") && payloadMap.containsKey("conversation") && payloadMap.containsKey("senderId")) {
Object messageObj = payloadMap.get("message");
Map<String, Object> conversationInfo = (Map<String, Object>) payloadMap.get("conversation");
Long conversationId = ((Number) conversationInfo.get("id")).longValue();
@@ -111,4 +122,4 @@ public class NotificationListener {
log.error("Failed to process and send message for user {}", username, e);
}
}
}
}