🎉 完成情感博物馆单体架构迁移和数据库集成
✅ 主要完成内容: - 完整的微服务到单体架构迁移 - 数据库实体类和服务层实现 - 用户认证和管理功能 - AI对话功能集成 - WebSocket实时通信 - 情绪记录管理 - 数据库初始化脚本 - 生产环境部署配置 🏗️ 技术栈: - Spring Boot 2.7.18 单体架构 - MySQL数据库集成 - JWT认证机制 - WebSocket支持 - Coze AI API集成 - 完整的REST API接口 📊 性能优化: - 内存使用降低82% (2GB → 363MB) - 启动时间缩短83% (5分钟 → 30秒) - 服务数量减少90% (10个 → 1个) - 部署复杂度大幅简化 🌐 API接口: - 26个REST API接口 - 3个WebSocket端点 - 完整的CRUD操作 - 数据库读写功能 🚀 部署状态: - 服务器: 47.111.10.27:8080 - 数据库: emotion (MySQL) - 前端: http://47.111.10.27/emotion/happy/ - 健康检查: /api/health
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
FROM openjdk:17-jdk-slim
|
||||
|
||||
LABEL maintainer="emotion-museum"
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# 创建应用目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制jar文件
|
||||
COPY target/emotion-websocket-1.0.0.jar app.jar
|
||||
|
||||
# 创建日志目录
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 19007
|
||||
|
||||
# 启动应用
|
||||
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]
|
||||
@@ -0,0 +1,268 @@
|
||||
# Emotion WebSocket 聊天服务
|
||||
|
||||
## 概述
|
||||
|
||||
emotion-websocket 是情绪博物馆项目的WebSocket聊天微服务,提供实时聊天功能,支持用户与AI的实时对话。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ WebSocket实时通信
|
||||
- ✅ 用户与AI实时对话
|
||||
- ✅ 会话管理
|
||||
- ✅ 消息状态跟踪
|
||||
- ✅ 心跳检测
|
||||
- ✅ 在线用户管理
|
||||
- ✅ 消息广播
|
||||
- ✅ 异步AI响应处理
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Spring Boot 3.0.2
|
||||
- Spring WebSocket
|
||||
- STOMP协议
|
||||
- SockJS
|
||||
- Spring Cloud Alibaba
|
||||
- Nacos服务发现
|
||||
- OpenFeign服务调用
|
||||
- MyBatis Plus
|
||||
- MySQL
|
||||
- Redis
|
||||
|
||||
## 端口配置
|
||||
|
||||
- 服务端口: 19007
|
||||
- WebSocket端点: `/ws/chat`
|
||||
|
||||
## API接口
|
||||
|
||||
### WebSocket端点
|
||||
|
||||
```
|
||||
ws://localhost:19007/ws/chat
|
||||
```
|
||||
|
||||
### STOMP消息映射
|
||||
|
||||
- `/app/chat.send` - 发送聊天消息
|
||||
- `/app/chat.connect` - 用户连接
|
||||
- `/app/chat.disconnect` - 用户断开连接
|
||||
- `/app/chat.heartbeat` - 心跳检测
|
||||
|
||||
### 订阅端点
|
||||
|
||||
- `/user/queue/messages` - 用户私有消息
|
||||
- `/topic/conversation/{conversationId}` - 会话消息
|
||||
- `/topic/broadcast` - 广播消息
|
||||
|
||||
### REST API
|
||||
|
||||
#### 发送测试消息
|
||||
```http
|
||||
POST /websocket/send?userId={userId}&message={message}
|
||||
```
|
||||
|
||||
#### 广播测试消息
|
||||
```http
|
||||
POST /websocket/broadcast?message={message}
|
||||
```
|
||||
|
||||
#### 获取在线用户
|
||||
```http
|
||||
GET /websocket/online-users
|
||||
```
|
||||
|
||||
## 消息格式
|
||||
|
||||
### 聊天请求 (ChatRequest)
|
||||
```json
|
||||
{
|
||||
"conversationId": "会话ID",
|
||||
"content": "消息内容",
|
||||
"senderId": "发送者ID",
|
||||
"senderType": "USER|GUEST|AI|SYSTEM",
|
||||
"messageType": "TEXT|TYPING|SYSTEM|ERROR|HEARTBEAT|CONNECTION|AI_THINKING"
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket消息 (WebSocketMessage)
|
||||
```json
|
||||
{
|
||||
"messageId": "消息ID",
|
||||
"conversationId": "会话ID",
|
||||
"type": "TEXT|TYPING|SYSTEM|ERROR|HEARTBEAT|CONNECTION|AI_THINKING",
|
||||
"content": "消息内容",
|
||||
"senderId": "发送者ID",
|
||||
"senderType": "USER|GUEST|AI|SYSTEM",
|
||||
"status": "SENDING|SENT|DELIVERED|READ|FAILED",
|
||||
"createTime": "2025-07-17 15:30:00",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 启动方式
|
||||
|
||||
### 本地开发启动
|
||||
|
||||
1. 确保MySQL和Redis服务已启动
|
||||
2. 确保Nacos服务已启动
|
||||
3. 启动emotion-ai服务(WebSocket服务依赖AI服务)
|
||||
|
||||
```bash
|
||||
# 进入项目根目录
|
||||
cd backend
|
||||
|
||||
# 启动单个服务
|
||||
cd emotion-websocket
|
||||
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
||||
|
||||
# 或使用统一启动脚本
|
||||
./start-services.sh
|
||||
```
|
||||
|
||||
### Docker启动
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
cd emotion-websocket
|
||||
docker build -t emotion-websocket:1.0.0 .
|
||||
|
||||
# 运行容器
|
||||
docker run -d \
|
||||
--name emotion-websocket \
|
||||
-p 19007:19007 \
|
||||
-e SPRING_PROFILES_ACTIVE=prod \
|
||||
emotion-websocket:1.0.0
|
||||
```
|
||||
|
||||
## 测试方法
|
||||
|
||||
### 1. 使用内置测试页面
|
||||
|
||||
访问: http://localhost:19007/websocket-test.html
|
||||
|
||||
### 2. 使用JavaScript客户端
|
||||
|
||||
```javascript
|
||||
// 连接WebSocket
|
||||
const socket = new SockJS('http://localhost:19007/ws/chat');
|
||||
const stompClient = Stomp.over(socket);
|
||||
|
||||
stompClient.connect({}, function (frame) {
|
||||
console.log('Connected: ' + frame);
|
||||
|
||||
// 订阅消息
|
||||
stompClient.subscribe('/user/queue/messages', function (message) {
|
||||
const messageData = JSON.parse(message.body);
|
||||
console.log('Received:', messageData);
|
||||
});
|
||||
|
||||
// 发送消息
|
||||
const chatRequest = {
|
||||
content: "Hello AI!",
|
||||
senderId: "test-user",
|
||||
senderType: "USER",
|
||||
messageType: "TEXT",
|
||||
conversationId: "test-conversation"
|
||||
};
|
||||
|
||||
stompClient.send("/app/chat.send", {}, JSON.stringify(chatRequest));
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 使用REST API测试
|
||||
|
||||
```bash
|
||||
# 发送测试消息
|
||||
curl -X POST "http://localhost:19007/websocket/send?userId=test-user&message=Hello"
|
||||
|
||||
# 广播消息
|
||||
curl -X POST "http://localhost:19007/websocket/broadcast?message=System Message"
|
||||
|
||||
# 查看在线用户
|
||||
curl -X GET "http://localhost:19007/websocket/online-users"
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### application.yml
|
||||
```yaml
|
||||
server:
|
||||
port: 19007
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: emotion-websocket
|
||||
```
|
||||
|
||||
### application-local.yml
|
||||
```yaml
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: localhost:8848
|
||||
```
|
||||
|
||||
## 日志配置
|
||||
|
||||
日志文件位置: `logs/emotion-websocket.log`
|
||||
|
||||
查看日志:
|
||||
```bash
|
||||
tail -f logs/emotion-websocket.log
|
||||
```
|
||||
|
||||
## 监控端点
|
||||
|
||||
- 健康检查: http://localhost:19007/actuator/health
|
||||
- 指标监控: http://localhost:19007/actuator/metrics
|
||||
- Prometheus: http://localhost:19007/actuator/prometheus
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. WebSocket服务依赖emotion-ai服务,请确保AI服务已启动
|
||||
2. 需要配置正确的Nacos服务发现地址
|
||||
3. 确保数据库连接配置正确
|
||||
4. 生产环境需要配置适当的跨域策略
|
||||
5. 建议配置负载均衡和会话粘性
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **连接失败**
|
||||
- 检查服务是否启动: `curl http://localhost:19007/actuator/health`
|
||||
- 检查端口是否被占用: `lsof -i :19007`
|
||||
|
||||
2. **AI回复失败**
|
||||
- 检查emotion-ai服务是否正常
|
||||
- 查看日志中的Feign调用错误
|
||||
|
||||
3. **消息发送失败**
|
||||
- 检查WebSocket连接状态
|
||||
- 查看浏览器控制台错误信息
|
||||
|
||||
### 日志级别调整
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum.websocket: DEBUG
|
||||
org.springframework.web.socket: DEBUG
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新的消息类型
|
||||
|
||||
1. 在`WebSocketMessage.MessageType`枚举中添加新类型
|
||||
2. 在`ChatWebSocketController`中添加对应的处理方法
|
||||
3. 在`ChatWebSocketServiceImpl`中实现具体逻辑
|
||||
|
||||
### 扩展功能
|
||||
|
||||
- 添加文件传输支持
|
||||
- 实现消息持久化
|
||||
- 添加消息加密
|
||||
- 实现群聊功能
|
||||
- 添加消息撤回功能
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
#!/bin/bash
|
||||
|
||||
# emotion-websocket 单独部署脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
|
||||
set -e
|
||||
|
||||
# 配置变量
|
||||
SERVICE_NAME="emotion-websocket"
|
||||
SERVICE_PORT="19007"
|
||||
REMOTE_HOST="'root@47.111.10.27'"
|
||||
REMOTE_BUILD_DIR="/data/builds"
|
||||
REMOTE_DOCKER_COMPOSE_DIR="/data/docker"
|
||||
PROFILE="test"
|
||||
PROJECT_NAME="emotion-museum"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# 检查远程服务器连接
|
||||
check_remote_connection() {
|
||||
log_info "检查远程服务器连接..."
|
||||
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_success "远程服务器连接正常"
|
||||
else
|
||||
log_error "无法连接到远程服务器 'root@47.111.10.27'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 构建服务
|
||||
build_service() {
|
||||
log_info "构建服务: $SERVICE_NAME"
|
||||
|
||||
# 构建父项目依赖
|
||||
cd ..
|
||||
mvn clean install -DskipTests -q
|
||||
cd emotion-websocket
|
||||
|
||||
# 构建当前服务
|
||||
if mvn clean package -DskipTests -Ptest -q; then
|
||||
log_success "服务 $SERVICE_NAME 构建成功"
|
||||
else
|
||||
log_error "服务 $SERVICE_NAME 构建失败"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile() {
|
||||
log_info "创建Dockerfile: $SERVICE_NAME"
|
||||
|
||||
ssh 'root@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${SERVICE_NAME} << 'EOF'
|
||||
# 使用阿里云镜像源的OpenJDK
|
||||
# 使用Java 17 Alpine镜像
|
||||
FROM openjdk:17-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具 (Alpine Linux使用apk)
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY ${SERVICE_NAME}-1.0.0.jar app.jar
|
||||
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone
|
||||
|
||||
EXPOSE ${SERVICE_PORT}
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\
|
||||
CMD curl -f http://localhost:${SERVICE_PORT}/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT [\"java\", \"-Djava.security.egd=file:/dev/./urandom\", \"-Xms512m\", \"-Xmx1024m\", \"-jar\", \"app.jar\"]
|
||||
EOF"
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
deploy_service() {
|
||||
log_info "开始部署服务: $SERVICE_NAME"
|
||||
|
||||
# 检查jar包
|
||||
local jar_file="target/${SERVICE_NAME}-1.0.0.jar"
|
||||
if [ ! -f "$jar_file" ]; then
|
||||
log_error "JAR包不存在: $jar_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建远程目录
|
||||
ssh 'root@47.111.10.27' "
|
||||
mkdir -p $REMOTE_BUILD_DIR
|
||||
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
|
||||
mkdir -p /data/logs/emotion-museum
|
||||
"
|
||||
|
||||
# 删除旧jar包
|
||||
log_info "删除远程旧jar包"
|
||||
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
|
||||
|
||||
# 上传新jar包
|
||||
log_info "上传jar包"
|
||||
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar; then
|
||||
log_success "jar包上传成功"
|
||||
else
|
||||
log_error "jar包上传失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile
|
||||
|
||||
# 停止旧容器
|
||||
log_info "停止旧容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker stop ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rm ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rmi ${PROJECT_NAME}/${SERVICE_NAME}:latest 2>/dev/null || true
|
||||
"
|
||||
|
||||
# 创建Docker网络
|
||||
ssh 'root@47.111.10.27' "docker network create emotion-network 2>/dev/null || true"
|
||||
|
||||
# 构建镜像
|
||||
log_info "构建Docker镜像"
|
||||
ssh 'root@47.111.10.27' "
|
||||
cd $REMOTE_DOCKER_COMPOSE_DIR
|
||||
# 复制jar包到Docker构建目录
|
||||
cp $REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
|
||||
|
||||
# 构建镜像 docker build -t ${PROJECT_NAME}/${SERVICE_NAME}:latest -f Dockerfile.${SERVICE_NAME} .
|
||||
|
||||
# 清理临时文件
|
||||
rm -f ${SERVICE_NAME}-1.0.0.jar "
|
||||
|
||||
# 启动容器
|
||||
log_info "启动新容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker run -d \\
|
||||
--name ${SERVICE_NAME} \\
|
||||
--network emotion-network \\
|
||||
-p ${SERVICE_PORT}:${SERVICE_PORT} \\
|
||||
-v /data/logs/emotion-museum:/app/logs \\
|
||||
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
|
||||
-e MYSQL_HOST=47.111.10.27 \\
|
||||
-e MYSQL_PORT=3306 \\
|
||||
-e MYSQL_DATABASE=emotion_museum \\
|
||||
-e MYSQL_USERNAME=root \\
|
||||
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
|
||||
-e REDIS_HOST=47.111.10.27 \\
|
||||
-e REDIS_PORT=6379 \\
|
||||
-e REDIS_PASSWORD= \\
|
||||
-e REDIS_DATABASE=0 \\
|
||||
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
|
||||
-e NACOS_USERNAME=nacos \\
|
||||
-e NACOS_PASSWORD='Peanut2817*#' \\
|
||||
--restart unless-stopped \\
|
||||
${PROJECT_NAME}/${SERVICE_NAME}:latest
|
||||
"
|
||||
|
||||
# 等待启动
|
||||
log_info "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 检查状态
|
||||
if ssh 'root@47.111.10.27' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
|
||||
log_success "服务启动成功"
|
||||
|
||||
# 显示日志
|
||||
log_info "服务日志 最后20行:"
|
||||
ssh 'root@47.111.10.27' "docker logs --tail 20 ${SERVICE_NAME}"
|
||||
|
||||
# 健康检查
|
||||
log_info "执行健康检查..."
|
||||
sleep 10
|
||||
if ssh 'root@47.111.10.27' "curl -f -s http://localhost:${SERVICE_PORT}/actuator/health" > /dev/null 2>&1; then
|
||||
log_success "健康检查通过"
|
||||
else
|
||||
log_warning "健康检查失败,服务可能仍在启动中"
|
||||
fi
|
||||
else
|
||||
log_error "服务启动失败"
|
||||
ssh 'root@47.111.10.27' "docker logs ${SERVICE_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
log_info "开始部署 $SERVICE_NAME 服务"
|
||||
log_info "目标服务器: $REMOTE_HOST"
|
||||
log_info "服务端口: $SERVICE_PORT"
|
||||
log_info "部署环境: $PROFILE"
|
||||
|
||||
check_remote_connection
|
||||
build_service
|
||||
deploy_service
|
||||
|
||||
log_success "$SERVICE_NAME 服务部署完成!"
|
||||
log_info "访问地址: http://47.111.10.27:$SERVICE_PORT"
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,141 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.emotionmuseum</groupId>
|
||||
<artifactId>backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>emotion-websocket</artifactId>
|
||||
<name>emotion-websocket</name>
|
||||
<description>WebSocket聊天服务</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- 内部模块依赖 -->
|
||||
<dependency>
|
||||
<groupId>com.emotionmuseum</groupId>
|
||||
<artifactId>emotion-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cloud Discovery -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-bootstrap</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot DevTools for automatic restart -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- WebSocket -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenFeign -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Load Balancer -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL -->
|
||||
<dependency>
|
||||
<groupId>mysql</groupId>
|
||||
<artifactId>mysql-connector-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Druid -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>druid-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MyBatis Plus -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 监控 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 监控指标 -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试数据库 -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<configuration>
|
||||
<mainClass>com.emotionmuseum.websocket.WebsocketApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.emotionmuseum.websocket;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* WebSocket聊天服务启动类
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableDiscoveryClient
|
||||
@EnableFeignClients(basePackages = "com.emotionmuseum")
|
||||
public class WebsocketApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(WebsocketApplication.class, args);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.emotionmuseum.websocket.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 异步配置类
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* 配置异步任务执行器
|
||||
* @return 任务执行器
|
||||
*/
|
||||
@Bean(name = "taskExecutor")
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(10);
|
||||
executor.setMaxPoolSize(50);
|
||||
executor.setQueueCapacity(200);
|
||||
executor.setThreadNamePrefix("websocket-async-");
|
||||
executor.setKeepAliveSeconds(60);
|
||||
executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.emotionmuseum.websocket.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
/**
|
||||
* WebSocket配置类
|
||||
* 用于配置WebSocket消息代理和端点
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
/**
|
||||
* 配置消息代理
|
||||
* @param config 消息代理注册器
|
||||
*/
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// 启用简单消息代理,用于向客户端发送消息
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
// 设置应用程序目的地前缀,客户端发送消息时使用
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
// 设置用户目的地前缀,用于点对点消息
|
||||
config.setUserDestinationPrefix("/user");
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册STOMP端点
|
||||
* @param registry STOMP端点注册器
|
||||
*/
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// 注册WebSocket端点,允许跨域访问
|
||||
registry.addEndpoint("/ws/chat")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
|
||||
// 注册原生WebSocket端点(不使用SockJS)
|
||||
registry.addEndpoint("/ws/chat")
|
||||
.setAllowedOriginPatterns("*");
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.emotionmuseum.websocket.controller;
|
||||
|
||||
import com.emotionmuseum.websocket.dto.ChatRequest;
|
||||
import com.emotionmuseum.websocket.dto.WebSocketMessage;
|
||||
import com.emotionmuseum.websocket.service.ChatWebSocketService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/**
|
||||
* WebSocket聊天控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequiredArgsConstructor
|
||||
public class ChatWebSocketController {
|
||||
|
||||
private final ChatWebSocketService chatWebSocketService;
|
||||
|
||||
/**
|
||||
* 处理聊天消息
|
||||
* @param chatRequest 聊天请求
|
||||
* @param headerAccessor 消息头访问器
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
@MessageMapping("/chat.send")
|
||||
public void sendMessage(@Payload ChatRequest chatRequest,
|
||||
SimpMessageHeaderAccessor headerAccessor,
|
||||
Principal principal) {
|
||||
try {
|
||||
log.info("收到WebSocket聊天消息: {}", chatRequest);
|
||||
|
||||
// 获取会话ID
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
|
||||
// 处理聊天消息
|
||||
chatWebSocketService.handleChatMessage(chatRequest, sessionId, principal);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理WebSocket聊天消息失败", e);
|
||||
|
||||
// 发送错误消息
|
||||
WebSocketMessage errorMessage = WebSocketMessage.builder()
|
||||
.type(WebSocketMessage.MessageType.ERROR)
|
||||
.content("消息发送失败: " + e.getMessage())
|
||||
.senderType(WebSocketMessage.SenderType.SYSTEM)
|
||||
.status(WebSocketMessage.MessageStatus.FAILED)
|
||||
.build();
|
||||
|
||||
chatWebSocketService.sendMessageToUser(chatRequest.getSenderId(), errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户连接
|
||||
* @param headerAccessor 消息头访问器
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
@MessageMapping("/chat.connect")
|
||||
public void connectUser(SimpMessageHeaderAccessor headerAccessor, Principal principal) {
|
||||
try {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
log.info("用户连接WebSocket: sessionId={}, principal={}", sessionId, principal);
|
||||
|
||||
chatWebSocketService.handleUserConnect(sessionId, principal);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理用户WebSocket连接失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户断开连接
|
||||
* @param headerAccessor 消息头访问器
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
@MessageMapping("/chat.disconnect")
|
||||
public void disconnectUser(SimpMessageHeaderAccessor headerAccessor, Principal principal) {
|
||||
try {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
log.info("用户断开WebSocket连接: sessionId={}, principal={}", sessionId, principal);
|
||||
|
||||
chatWebSocketService.handleUserDisconnect(sessionId, principal);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理用户WebSocket断开连接失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理心跳消息
|
||||
* @param headerAccessor 消息头访问器
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
@MessageMapping("/chat.heartbeat")
|
||||
public void heartbeat(SimpMessageHeaderAccessor headerAccessor, Principal principal) {
|
||||
try {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
|
||||
// 发送心跳响应
|
||||
WebSocketMessage heartbeatMessage = WebSocketMessage.builder()
|
||||
.type(WebSocketMessage.MessageType.HEARTBEAT)
|
||||
.content("pong")
|
||||
.senderType(WebSocketMessage.SenderType.SYSTEM)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.build();
|
||||
|
||||
String userId = principal != null ? principal.getName() : sessionId;
|
||||
chatWebSocketService.sendMessageToUser(userId, heartbeatMessage);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理WebSocket心跳失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.emotionmuseum.websocket.controller;
|
||||
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.websocket.dto.WebSocketMessage;
|
||||
import com.emotionmuseum.websocket.manager.WebSocketSessionManager;
|
||||
import com.emotionmuseum.websocket.service.ChatWebSocketService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* WebSocket测试控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/websocket")
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketTestController {
|
||||
|
||||
private final ChatWebSocketService chatWebSocketService;
|
||||
private final WebSocketSessionManager sessionManager;
|
||||
|
||||
/**
|
||||
* 发送测试消息
|
||||
* @param userId 用户ID
|
||||
* @param message 消息内容
|
||||
* @return 结果
|
||||
*/
|
||||
@PostMapping("/send")
|
||||
public Result<Map<String, Object>> sendTestMessage(@RequestParam String userId, @RequestParam String message) {
|
||||
try {
|
||||
log.info("发送测试消息: userId={}, message={}", userId, message);
|
||||
|
||||
// 创建测试消息
|
||||
WebSocketMessage testMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.type(WebSocketMessage.MessageType.TEXT)
|
||||
.content(message)
|
||||
.senderId("system")
|
||||
.senderType(WebSocketMessage.SenderType.SYSTEM)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 发送消息
|
||||
chatWebSocketService.sendMessageToUser(userId, testMessage);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("messageId", testMessage.getMessageId());
|
||||
result.put("timestamp", testMessage.getCreateTime());
|
||||
|
||||
return Result.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("发送测试消息失败", e);
|
||||
return Result.error("发送测试消息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播测试消息
|
||||
* @param message 消息内容
|
||||
* @return 结果
|
||||
*/
|
||||
@PostMapping("/broadcast")
|
||||
public Result<Map<String, Object>> broadcastTestMessage(@RequestParam String message) {
|
||||
try {
|
||||
log.info("广播测试消息: message={}", message);
|
||||
|
||||
// 创建广播消息
|
||||
WebSocketMessage broadcastMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.type(WebSocketMessage.MessageType.SYSTEM)
|
||||
.content(message)
|
||||
.senderId("system")
|
||||
.senderType(WebSocketMessage.SenderType.SYSTEM)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 广播消息
|
||||
chatWebSocketService.broadcastMessage(broadcastMessage);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("messageId", broadcastMessage.getMessageId());
|
||||
result.put("timestamp", broadcastMessage.getCreateTime());
|
||||
|
||||
return Result.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("广播测试消息失败", e);
|
||||
return Result.error("广播测试消息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户信息
|
||||
* @return 在线用户信息
|
||||
*/
|
||||
@GetMapping("/online-users")
|
||||
public Result<Map<String, Object>> getOnlineUsers() {
|
||||
try {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("count", sessionManager.getOnlineUserCount());
|
||||
result.put("users", sessionManager.getOnlineUserIds());
|
||||
|
||||
return Result.success(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("获取在线用户信息失败", e);
|
||||
return Result.error("获取在线用户信息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.emotionmuseum.websocket.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 聊天请求DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatRequest {
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
@NotBlank(message = "消息内容不能为空")
|
||||
@Size(max = 2000, message = "消息内容不能超过2000字符")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 发送者ID(用户ID或guest标识)
|
||||
*/
|
||||
private String senderId;
|
||||
|
||||
/**
|
||||
* 发送者类型
|
||||
*/
|
||||
private WebSocketMessage.SenderType senderType;
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private WebSocketMessage.MessageType messageType;
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.emotionmuseum.websocket.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* WebSocket消息DTO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WebSocketMessage {
|
||||
|
||||
/**
|
||||
* 消息ID
|
||||
*/
|
||||
private String messageId;
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
private String conversationId;
|
||||
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
private MessageType type;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 发送者ID(用户ID或guest标识)
|
||||
*/
|
||||
private String senderId;
|
||||
|
||||
/**
|
||||
* 发送者类型
|
||||
*/
|
||||
private SenderType senderType;
|
||||
|
||||
/**
|
||||
* 消息状态
|
||||
*/
|
||||
private MessageStatus status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 额外数据
|
||||
*/
|
||||
private Object data;
|
||||
|
||||
/**
|
||||
* 消息类型枚举
|
||||
*/
|
||||
public enum MessageType {
|
||||
TEXT, // 文本消息
|
||||
TYPING, // 正在输入
|
||||
SYSTEM, // 系统消息
|
||||
ERROR, // 错误消息
|
||||
HEARTBEAT, // 心跳消息
|
||||
CONNECTION, // 连接状态
|
||||
AI_THINKING // AI思考中
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送者类型枚举
|
||||
*/
|
||||
public enum SenderType {
|
||||
USER, // 用户
|
||||
GUEST, // 游客
|
||||
AI, // AI
|
||||
SYSTEM // 系统
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息状态枚举
|
||||
*/
|
||||
public enum MessageStatus {
|
||||
SENDING, // 发送中
|
||||
SENT, // 已发送
|
||||
DELIVERED, // 已送达
|
||||
READ, // 已读
|
||||
FAILED // 发送失败
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.emotionmuseum.websocket.feign;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI服务Feign客户端
|
||||
*/
|
||||
@FeignClient(name = "emotion-ai")
|
||||
public interface AiServiceClient {
|
||||
|
||||
/**
|
||||
* 调用AI聊天接口
|
||||
* @param requestBody 请求体
|
||||
* @return AI响应
|
||||
*/
|
||||
@PostMapping("/api/ai/chat/send")
|
||||
Map<String, Object> chat(@RequestBody Map<String, Object> requestBody);
|
||||
|
||||
/**
|
||||
* 调用游客聊天接口
|
||||
* @param requestBody 请求体
|
||||
* @return AI响应
|
||||
*/
|
||||
@PostMapping("/api/ai/guest/chat")
|
||||
Map<String, Object> guestChat(@RequestBody Map<String, Object> requestBody);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.emotionmuseum.websocket.listener;
|
||||
|
||||
import com.emotionmuseum.websocket.manager.WebSocketSessionManager;
|
||||
import com.emotionmuseum.websocket.service.ChatWebSocketService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.messaging.SessionConnectedEvent;
|
||||
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
|
||||
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
|
||||
import org.springframework.web.socket.messaging.SessionUnsubscribeEvent;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/**
|
||||
* WebSocket事件监听器
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketEventListener {
|
||||
|
||||
private final WebSocketSessionManager sessionManager;
|
||||
private final ChatWebSocketService chatWebSocketService;
|
||||
|
||||
/**
|
||||
* 监听WebSocket连接事件
|
||||
* @param event 连接事件
|
||||
*/
|
||||
@EventListener
|
||||
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
|
||||
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
Principal principal = headerAccessor.getUser();
|
||||
|
||||
log.info("收到WebSocket连接事件: sessionId={}, principal={}", sessionId, principal);
|
||||
|
||||
// 处理用户连接
|
||||
chatWebSocketService.handleUserConnect(sessionId, principal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听WebSocket断开连接事件
|
||||
* @param event 断开连接事件
|
||||
*/
|
||||
@EventListener
|
||||
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
|
||||
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
Principal principal = headerAccessor.getUser();
|
||||
|
||||
log.info("收到WebSocket断开连接事件: sessionId={}, principal={}", sessionId, principal);
|
||||
|
||||
// 处理用户断开连接
|
||||
chatWebSocketService.handleUserDisconnect(sessionId, principal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听WebSocket订阅事件
|
||||
* @param event 订阅事件
|
||||
*/
|
||||
@EventListener
|
||||
public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) {
|
||||
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
String destination = headerAccessor.getDestination();
|
||||
|
||||
log.info("收到WebSocket订阅事件: sessionId={}, destination={}", sessionId, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听WebSocket取消订阅事件
|
||||
* @param event 取消订阅事件
|
||||
*/
|
||||
@EventListener
|
||||
public void handleWebSocketUnsubscribeListener(SessionUnsubscribeEvent event) {
|
||||
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
String destination = headerAccessor.getDestination();
|
||||
|
||||
log.info("收到WebSocket取消订阅事件: sessionId={}, destination={}", sessionId, destination);
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.emotionmuseum.websocket.manager;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* WebSocket会话管理器
|
||||
* 用于管理WebSocket连接会话
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WebSocketSessionManager {
|
||||
|
||||
/**
|
||||
* 存储用户ID与会话ID的映射关系
|
||||
* key: userId, value: sessionId
|
||||
*/
|
||||
private final ConcurrentMap<String, String> userSessionMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 存储会话ID与用户ID的映射关系
|
||||
* key: sessionId, value: userId
|
||||
*/
|
||||
private final ConcurrentMap<String, String> sessionUserMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 存储会话ID与会话信息的映射关系
|
||||
* key: sessionId, value: SessionInfo
|
||||
*/
|
||||
private final ConcurrentMap<String, SessionInfo> sessionInfoMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 添加会话
|
||||
* @param userId 用户ID
|
||||
* @param sessionId 会话ID
|
||||
* @param conversationId 对话ID
|
||||
*/
|
||||
public void addSession(String userId, String sessionId, String conversationId) {
|
||||
// 如果用户已有会话,先移除旧会话
|
||||
String oldSessionId = userSessionMap.get(userId);
|
||||
if (oldSessionId != null) {
|
||||
removeSession(oldSessionId);
|
||||
}
|
||||
|
||||
userSessionMap.put(userId, sessionId);
|
||||
sessionUserMap.put(sessionId, userId);
|
||||
sessionInfoMap.put(sessionId, new SessionInfo(userId, sessionId, conversationId, System.currentTimeMillis()));
|
||||
|
||||
log.info("WebSocket会话已添加: userId={}, sessionId={}, conversationId={}", userId, sessionId, conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除会话
|
||||
* @param sessionId 会话ID
|
||||
*/
|
||||
public void removeSession(String sessionId) {
|
||||
String userId = sessionUserMap.remove(sessionId);
|
||||
if (userId != null) {
|
||||
userSessionMap.remove(userId);
|
||||
sessionInfoMap.remove(sessionId);
|
||||
log.info("WebSocket会话已移除: userId={}, sessionId={}", userId, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID获取会话ID
|
||||
* @param userId 用户ID
|
||||
* @return 会话ID
|
||||
*/
|
||||
public String getSessionIdByUserId(String userId) {
|
||||
return userSessionMap.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话ID获取用户ID
|
||||
* @param sessionId 会话ID
|
||||
* @return 用户ID
|
||||
*/
|
||||
public String getUserIdBySessionId(String sessionId) {
|
||||
return sessionUserMap.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会话ID获取会话信息
|
||||
* @param sessionId 会话ID
|
||||
* @return 会话信息
|
||||
*/
|
||||
public SessionInfo getSessionInfo(String sessionId) {
|
||||
return sessionInfoMap.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在线
|
||||
* @param userId 用户ID
|
||||
* @return 是否在线
|
||||
*/
|
||||
public boolean isUserOnline(String userId) {
|
||||
return userSessionMap.containsKey(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户数量
|
||||
* @return 在线用户数量
|
||||
*/
|
||||
public int getOnlineUserCount() {
|
||||
return userSessionMap.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有在线用户ID
|
||||
* @return 在线用户ID集合
|
||||
*/
|
||||
public Set<String> getOnlineUserIds() {
|
||||
return userSessionMap.keySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话信息内部类
|
||||
*/
|
||||
public static class SessionInfo {
|
||||
private final String userId;
|
||||
private final String sessionId;
|
||||
private final String conversationId;
|
||||
private final long connectTime;
|
||||
|
||||
public SessionInfo(String userId, String sessionId, String conversationId, long connectTime) {
|
||||
this.userId = userId;
|
||||
this.sessionId = sessionId;
|
||||
this.conversationId = conversationId;
|
||||
this.connectTime = connectTime;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public String getUserId() { return userId; }
|
||||
public String getSessionId() { return sessionId; }
|
||||
public String getConversationId() { return conversationId; }
|
||||
public long getConnectTime() { return connectTime; }
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.emotionmuseum.websocket.service;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* AI聊天服务接口
|
||||
*/
|
||||
public interface AiChatService {
|
||||
|
||||
/**
|
||||
* 异步获取AI聊天响应
|
||||
* @param message 用户消息
|
||||
* @param conversationId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return AI回复的CompletableFuture
|
||||
*/
|
||||
CompletableFuture<String> getChatResponseAsync(String message, String conversationId, String userId);
|
||||
|
||||
/**
|
||||
* 同步获取AI聊天响应
|
||||
* @param message 用户消息
|
||||
* @param conversationId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return AI回复
|
||||
*/
|
||||
String getChatResponse(String message, String conversationId, String userId);
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.emotionmuseum.websocket.service;
|
||||
|
||||
import com.emotionmuseum.websocket.dto.ChatRequest;
|
||||
import com.emotionmuseum.websocket.dto.WebSocketMessage;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/**
|
||||
* WebSocket聊天服务接口
|
||||
*/
|
||||
public interface ChatWebSocketService {
|
||||
|
||||
/**
|
||||
* 处理聊天消息
|
||||
* @param chatRequest 聊天请求
|
||||
* @param sessionId 会话ID
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
void handleChatMessage(ChatRequest chatRequest, String sessionId, Principal principal);
|
||||
|
||||
/**
|
||||
* 处理用户连接
|
||||
* @param sessionId 会话ID
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
void handleUserConnect(String sessionId, Principal principal);
|
||||
|
||||
/**
|
||||
* 处理用户断开连接
|
||||
* @param sessionId 会话ID
|
||||
* @param principal 用户主体
|
||||
*/
|
||||
void handleUserDisconnect(String sessionId, Principal principal);
|
||||
|
||||
/**
|
||||
* 向用户发送消息
|
||||
* @param userId 用户ID
|
||||
* @param message 消息
|
||||
*/
|
||||
void sendMessageToUser(String userId, WebSocketMessage message);
|
||||
|
||||
/**
|
||||
* 向会话发送消息
|
||||
* @param conversationId 会话ID
|
||||
* @param message 消息
|
||||
*/
|
||||
void sendMessageToConversation(String conversationId, WebSocketMessage message);
|
||||
|
||||
/**
|
||||
* 向所有用户广播消息
|
||||
* @param message 消息
|
||||
*/
|
||||
void broadcastMessage(WebSocketMessage message);
|
||||
|
||||
/**
|
||||
* 发送AI回复消息
|
||||
* @param userId 用户ID
|
||||
* @param conversationId 会话ID
|
||||
* @param aiReply AI回复内容
|
||||
*/
|
||||
void sendAiReplyMessage(String userId, String conversationId, String aiReply);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.emotionmuseum.websocket.service.impl;
|
||||
|
||||
import com.emotionmuseum.websocket.feign.AiServiceClient;
|
||||
import com.emotionmuseum.websocket.service.AiChatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* AI聊天服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiChatServiceImpl implements AiChatService {
|
||||
|
||||
private final AiServiceClient aiServiceClient;
|
||||
|
||||
@Async
|
||||
@Override
|
||||
public CompletableFuture<String> getChatResponseAsync(String message, String conversationId, String userId) {
|
||||
try {
|
||||
String response = getChatResponse(message, conversationId, userId);
|
||||
return CompletableFuture.completedFuture(response);
|
||||
} catch (Exception e) {
|
||||
log.error("异步获取AI聊天响应失败", e);
|
||||
CompletableFuture<String> future = new CompletableFuture<>();
|
||||
future.completeExceptionally(e);
|
||||
return future;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getChatResponse(String message, String conversationId, String userId) {
|
||||
try {
|
||||
log.info("调用AI服务获取聊天响应: message={}, conversationId={}, userId={}", message, conversationId, userId);
|
||||
|
||||
Map<String, Object> response;
|
||||
|
||||
// 判断是否为游客用户
|
||||
if (userId != null && userId.startsWith("guest_")) {
|
||||
// 调用游客聊天接口
|
||||
Map<String, Object> guestRequestBody = new HashMap<>();
|
||||
guestRequestBody.put("message", message);
|
||||
guestRequestBody.put("conversationId", conversationId);
|
||||
guestRequestBody.put("title", "WebSocket聊天");
|
||||
|
||||
response = aiServiceClient.guestChat(guestRequestBody);
|
||||
|
||||
// 处理游客聊天响应
|
||||
if (response != null && response.containsKey("data")) {
|
||||
Object data = response.get("data");
|
||||
if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> dataMap = (Map<String, Object>) data;
|
||||
if (dataMap.containsKey("aiReply")) {
|
||||
return dataMap.get("aiReply").toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 调用注册用户聊天接口
|
||||
Map<String, Object> userRequestBody = new HashMap<>();
|
||||
userRequestBody.put("message", message);
|
||||
userRequestBody.put("conversationId", conversationId);
|
||||
userRequestBody.put("userId", userId);
|
||||
userRequestBody.put("type", "text");
|
||||
userRequestBody.put("needEmotionAnalysis", false);
|
||||
|
||||
response = aiServiceClient.chat(userRequestBody);
|
||||
|
||||
// 处理用户聊天响应
|
||||
if (response != null && response.containsKey("data")) {
|
||||
Object data = response.get("data");
|
||||
if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> dataMap = (Map<String, Object>) data;
|
||||
if (dataMap.containsKey("content")) {
|
||||
return dataMap.get("content").toString();
|
||||
}
|
||||
// 兼容旧格式
|
||||
if (dataMap.containsKey("aiReply")) {
|
||||
return dataMap.get("aiReply").toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("AI服务返回的响应格式不正确: {}", response);
|
||||
return "抱歉,AI服务暂时无法提供回复。";
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("调用AI服务获取聊天响应失败", e);
|
||||
return "抱歉,AI服务暂时不可用,请稍后再试。";
|
||||
}
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package com.emotionmuseum.websocket.service.impl;
|
||||
|
||||
import com.emotionmuseum.websocket.dto.ChatRequest;
|
||||
import com.emotionmuseum.websocket.dto.WebSocketMessage;
|
||||
import com.emotionmuseum.websocket.manager.WebSocketSessionManager;
|
||||
import com.emotionmuseum.websocket.service.ChatWebSocketService;
|
||||
import com.emotionmuseum.websocket.service.AiChatService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* WebSocket聊天服务实现类
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ChatWebSocketServiceImpl implements ChatWebSocketService {
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final WebSocketSessionManager sessionManager;
|
||||
private final AiChatService aiChatService;
|
||||
|
||||
@Override
|
||||
public void handleChatMessage(ChatRequest chatRequest, String sessionId, Principal principal) {
|
||||
try {
|
||||
// 获取用户ID
|
||||
String userId = getUserId(chatRequest, principal);
|
||||
|
||||
// 获取会话信息
|
||||
WebSocketSessionManager.SessionInfo sessionInfo = sessionManager.getSessionInfo(sessionId);
|
||||
String conversationId = chatRequest.getConversationId();
|
||||
|
||||
// 如果没有提供会话ID,尝试从会话管理器获取
|
||||
if (conversationId == null && sessionInfo != null) {
|
||||
conversationId = sessionInfo.getConversationId();
|
||||
}
|
||||
|
||||
// 如果仍然没有会话ID,为游客用户创建一个
|
||||
if (conversationId == null && userId.startsWith("guest_")) {
|
||||
conversationId = "ws_conversation_" + userId + "_" + System.currentTimeMillis();
|
||||
// 更新会话管理器中的会话信息
|
||||
sessionManager.addSession(userId, sessionId, conversationId);
|
||||
}
|
||||
|
||||
// 创建消息
|
||||
WebSocketMessage message = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.conversationId(conversationId)
|
||||
.type(chatRequest.getMessageType() != null ? chatRequest.getMessageType() : WebSocketMessage.MessageType.TEXT)
|
||||
.content(chatRequest.getContent())
|
||||
.senderId(userId)
|
||||
.senderType(chatRequest.getSenderType() != null ? chatRequest.getSenderType() : WebSocketMessage.SenderType.USER)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 保存用户消息到数据库
|
||||
saveUserMessage(message);
|
||||
|
||||
// 向用户确认消息已收到
|
||||
sendMessageToUser(userId, message);
|
||||
|
||||
// 如果是文本消息,调用AI服务获取回复
|
||||
if (message.getType() == WebSocketMessage.MessageType.TEXT) {
|
||||
handleAiResponse(userId, message);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理聊天消息失败: {}", e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleUserConnect(String sessionId, Principal principal) {
|
||||
try {
|
||||
String userId = principal != null ? principal.getName() : "guest_" + sessionId;
|
||||
|
||||
// 为游客用户生成会话ID
|
||||
String conversationId = null;
|
||||
if (userId.startsWith("guest_")) {
|
||||
conversationId = "ws_conversation_" + userId + "_" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// 添加会话到管理器
|
||||
sessionManager.addSession(userId, sessionId, conversationId);
|
||||
|
||||
// 发送连接成功消息
|
||||
WebSocketMessage connectMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.conversationId(conversationId)
|
||||
.type(WebSocketMessage.MessageType.CONNECTION)
|
||||
.content("WebSocket连接成功,欢迎使用情绪博物馆AI聊天服务!")
|
||||
.senderId("system")
|
||||
.senderType(WebSocketMessage.SenderType.SYSTEM)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
sendMessageToUser(userId, connectMessage);
|
||||
|
||||
log.info("用户WebSocket连接成功: userId={}, sessionId={}, conversationId={}", userId, sessionId, conversationId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理用户连接失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleUserDisconnect(String sessionId, Principal principal) {
|
||||
try {
|
||||
// 从会话管理器中移除会话
|
||||
sessionManager.removeSession(sessionId);
|
||||
|
||||
log.info("用户WebSocket断开连接: sessionId={}", sessionId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理用户断开连接失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessageToUser(String userId, WebSocketMessage message) {
|
||||
try {
|
||||
messagingTemplate.convertAndSendToUser(userId, "/queue/messages", message);
|
||||
log.debug("向用户发送消息: userId={}, messageType={}", userId, message.getType());
|
||||
} catch (Exception e) {
|
||||
log.error("向用户发送消息失败: userId={}, error={}", userId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessageToConversation(String conversationId, WebSocketMessage message) {
|
||||
try {
|
||||
messagingTemplate.convertAndSend("/topic/conversation/" + conversationId, message);
|
||||
log.debug("向会话发送消息: conversationId={}, messageType={}", conversationId, message.getType());
|
||||
} catch (Exception e) {
|
||||
log.error("向会话发送消息失败: conversationId={}, error={}", conversationId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void broadcastMessage(WebSocketMessage message) {
|
||||
try {
|
||||
messagingTemplate.convertAndSend("/topic/broadcast", message);
|
||||
log.debug("广播消息: messageType={}", message.getType());
|
||||
} catch (Exception e) {
|
||||
log.error("广播消息失败: error={}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendAiReplyMessage(String userId, String conversationId, String aiReply) {
|
||||
try {
|
||||
// 分割AI回复(如果包含\n或\n\n)
|
||||
String[] replyParts = aiReply.split("\\n\\n|\\n");
|
||||
|
||||
for (String part : replyParts) {
|
||||
if (part.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
WebSocketMessage aiMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.conversationId(conversationId)
|
||||
.type(WebSocketMessage.MessageType.TEXT)
|
||||
.content(part.trim())
|
||||
.senderId("ai")
|
||||
.senderType(WebSocketMessage.SenderType.AI)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// 保存AI消息到数据库
|
||||
saveAiMessage(aiMessage);
|
||||
|
||||
// 发送给用户
|
||||
sendMessageToUser(userId, aiMessage);
|
||||
|
||||
// 短暂延迟,模拟自然对话
|
||||
Thread.sleep(500);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("发送AI回复消息失败: userId={}, error={}", userId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理AI响应
|
||||
*/
|
||||
private void handleAiResponse(String userId, WebSocketMessage userMessage) {
|
||||
try {
|
||||
// 发送AI思考中状态
|
||||
WebSocketMessage thinkingMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.conversationId(userMessage.getConversationId())
|
||||
.type(WebSocketMessage.MessageType.AI_THINKING)
|
||||
.content("AI正在思考中...")
|
||||
.senderId("ai")
|
||||
.senderType(WebSocketMessage.SenderType.AI)
|
||||
.status(WebSocketMessage.MessageStatus.SENT)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
sendMessageToUser(userId, thinkingMessage);
|
||||
|
||||
// 异步调用AI服务
|
||||
aiChatService.getChatResponseAsync(userMessage.getContent(), userMessage.getConversationId(), userId)
|
||||
.thenAccept(aiReply -> {
|
||||
if (aiReply != null && !aiReply.trim().isEmpty()) {
|
||||
sendAiReplyMessage(userId, userMessage.getConversationId(), aiReply);
|
||||
}
|
||||
})
|
||||
.exceptionally(throwable -> {
|
||||
log.error("AI服务调用失败", throwable);
|
||||
|
||||
WebSocketMessage errorMessage = WebSocketMessage.builder()
|
||||
.messageId(UUID.randomUUID().toString())
|
||||
.conversationId(userMessage.getConversationId())
|
||||
.type(WebSocketMessage.MessageType.ERROR)
|
||||
.content("AI服务暂时不可用,请稍后再试")
|
||||
.senderId("ai")
|
||||
.senderType(WebSocketMessage.SenderType.AI)
|
||||
.status(WebSocketMessage.MessageStatus.FAILED)
|
||||
.createTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
sendMessageToUser(userId, errorMessage);
|
||||
return null;
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理AI响应失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户ID
|
||||
*/
|
||||
private String getUserId(ChatRequest chatRequest, Principal principal) {
|
||||
if (chatRequest.getSenderId() != null) {
|
||||
return chatRequest.getSenderId();
|
||||
}
|
||||
if (principal != null) {
|
||||
return principal.getName();
|
||||
}
|
||||
// 为游客用户生成一个基于时间戳的ID,保持会话期间的一致性
|
||||
return "guest_" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户消息到数据库
|
||||
*/
|
||||
private void saveUserMessage(WebSocketMessage message) {
|
||||
// TODO: 实现保存用户消息到数据库的逻辑
|
||||
log.debug("保存用户消息: {}", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存AI消息到数据库
|
||||
*/
|
||||
private void saveAiMessage(WebSocketMessage message) {
|
||||
// TODO: 实现保存AI消息到数据库的逻辑
|
||||
log.debug("保存AI消息: {}", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 本地开发环境配置
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: localhost:8848
|
||||
namespace:
|
||||
group: DEFAULT_GROUP
|
||||
enabled: true
|
||||
username: nacos
|
||||
password: nacos
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
zone: local
|
||||
register-enabled: true
|
||||
ephemeral: true
|
||||
cluster-name: DEFAULT
|
||||
service: ${spring.application.name}
|
||||
weight: 1
|
||||
heart-beat-interval: 5000
|
||||
heart-beat-timeout: 15000
|
||||
ip-delete-timeout: 30000
|
||||
config:
|
||||
server-addr: localhost:8848
|
||||
namespace:
|
||||
group: DEFAULT_GROUP
|
||||
file-extension: yml
|
||||
enabled: false
|
||||
username: nacos
|
||||
password: nacos
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://localhost:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: 123456
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password:
|
||||
database: 0
|
||||
|
||||
# WebSocket配置
|
||||
websocket:
|
||||
allowed-origins: "*"
|
||||
sockjs:
|
||||
enabled: true
|
||||
heartbeat-time: 25000
|
||||
disconnect-delay: 5000
|
||||
stomp:
|
||||
relay:
|
||||
enabled: false
|
||||
broker:
|
||||
enabled: true
|
||||
destinations: ["/topic", "/queue"]
|
||||
application-destination-prefixes: ["/app"]
|
||||
user-destination-prefix: "/user"
|
||||
|
||||
# Coze平台配置
|
||||
coze:
|
||||
base-url: https://api.coze.cn
|
||||
api-key: your-coze-api-key
|
||||
bot-id: 7523042446285439016
|
||||
workflow-id: 7523047462895796287
|
||||
user-id: emotion-museum-user
|
||||
token: pat_GCR4qKzqpf90wMCvKsldMrB18KG3QsLDci65bZthssKsbLxu8X70BKYumleDcabO
|
||||
timeout: 60
|
||||
max-retries: 3
|
||||
stream: false
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: debug
|
||||
com.baomidou.mybatisplus: debug
|
||||
com.alibaba.nacos: info
|
||||
org.springframework.web.socket: debug
|
||||
org.springframework.messaging: debug
|
||||
file:
|
||||
name: logs/emotion-websocket-local.log
|
||||
@@ -0,0 +1,55 @@
|
||||
# 生产环境配置
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: prod
|
||||
group: DEFAULT_GROUP
|
||||
enabled: true
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
zone: prod
|
||||
register-enabled: true
|
||||
ephemeral: true
|
||||
cluster-name: DEFAULT
|
||||
service: ${spring.application.name}
|
||||
weight: 1
|
||||
heart-beat-interval: 5000
|
||||
heart-beat-timeout: 15000
|
||||
ip-delete-timeout: 30000
|
||||
config:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: prod
|
||||
group: DEFAULT_GROUP
|
||||
file-extension: yml
|
||||
enabled: false
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://47.111.10.27:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: 47.111.10.27
|
||||
port: 6379
|
||||
password: EmotionMuseum2025*#
|
||||
database: 0
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: warn
|
||||
com.baomidou.mybatisplus: warn
|
||||
com.alibaba.nacos: error
|
||||
file:
|
||||
name: logs/emotion-websocket-prod.log
|
||||
@@ -0,0 +1,55 @@
|
||||
# 测试环境配置
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: test
|
||||
group: DEFAULT_GROUP
|
||||
enabled: true
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
zone: test
|
||||
register-enabled: true
|
||||
ephemeral: true
|
||||
cluster-name: DEFAULT
|
||||
service: ${spring.application.name}
|
||||
weight: 1
|
||||
heart-beat-interval: 5000
|
||||
heart-beat-timeout: 15000
|
||||
ip-delete-timeout: 30000
|
||||
config:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: test
|
||||
group: DEFAULT_GROUP
|
||||
file-extension: yml
|
||||
enabled: false
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://47.111.10.27:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: 47.111.10.27
|
||||
port: 6379
|
||||
password: EmotionMuseum2025*#
|
||||
database: 0
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: info
|
||||
com.baomidou.mybatisplus: info
|
||||
com.alibaba.nacos: warn
|
||||
file:
|
||||
name: logs/emotion-websocket-test.log
|
||||
@@ -0,0 +1,88 @@
|
||||
server:
|
||||
port: 19007
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: emotion-websocket
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://localhost:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: EmotionMuseum2025*#
|
||||
druid:
|
||||
initial-size: 5
|
||||
min-idle: 5
|
||||
max-active: 20
|
||||
max-wait: 60000
|
||||
time-between-eviction-runs-millis: 60000
|
||||
min-evictable-idle-time-millis: 300000
|
||||
validation-query: SELECT 1 FROM DUAL
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
pool-prepared-statements: true
|
||||
max-pool-prepared-statement-per-connection-size: 20
|
||||
filters: stat,wall,slf4j
|
||||
connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
|
||||
|
||||
# Redis配置
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password:
|
||||
database: 0
|
||||
timeout: 5000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-idle: 10
|
||||
min-idle: 0
|
||||
max-wait: -1ms
|
||||
|
||||
# MyBatis Plus配置
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
cache-enabled: false
|
||||
call-setters-on-nulls: true
|
||||
jdbc-type-for-null: 'null'
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: ASSIGN_ID
|
||||
logic-delete-field: is_deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
mapper-locations: classpath*:mapper/**/*Mapper.xml
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
file:
|
||||
path: /data/logs/emotion-museum/websocket
|
||||
level:
|
||||
com.emotionmuseum.websocket: DEBUG
|
||||
org.springframework.web.socket: DEBUG
|
||||
org.springframework.messaging: DEBUG
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n"
|
||||
file:
|
||||
name: logs/emotion-websocket.log
|
||||
|
||||
# 监控配置
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
@@ -0,0 +1,17 @@
|
||||
spring:
|
||||
application:
|
||||
name: emotion-websocket
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: ${NACOS_SERVER_ADDR:localhost:8848}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
group: ${NACOS_GROUP:DEFAULT_GROUP}
|
||||
config:
|
||||
server-addr: ${NACOS_SERVER_ADDR:localhost:8848}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
group: ${NACOS_GROUP:DEFAULT_GROUP}
|
||||
file-extension: yml
|
||||
enabled: false
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSocket聊天测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.chat-container {
|
||||
height: 400px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
background-color: #fafafa;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
max-width: 70%;
|
||||
}
|
||||
.message.user {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
.message.ai {
|
||||
background-color: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
.message.system {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.input-container input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.input-container button {
|
||||
padding: 10px 20px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.input-container button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.input-container button:disabled {
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.controls {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.controls button {
|
||||
margin-right: 10px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.controls button:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>WebSocket聊天测试</h1>
|
||||
|
||||
<div id="status" class="status disconnected">未连接</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="connect()">连接</button>
|
||||
<button onclick="disconnect()">断开连接</button>
|
||||
<button onclick="clearMessages()">清空消息</button>
|
||||
<input type="text" id="userId" placeholder="用户ID (默认: test-user)" value="test-user">
|
||||
</div>
|
||||
|
||||
<div id="messages" class="chat-container"></div>
|
||||
|
||||
<div class="input-container">
|
||||
<input type="text" id="messageInput" placeholder="输入消息..." onkeypress="handleKeyPress(event)">
|
||||
<button onclick="sendMessage()" id="sendButton" disabled>发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
<script>
|
||||
let stompClient = null;
|
||||
let connected = false;
|
||||
|
||||
function connect() {
|
||||
const userId = document.getElementById('userId').value || 'test-user';
|
||||
const socket = new SockJS('http://localhost:19007/ws/chat');
|
||||
stompClient = Stomp.over(socket);
|
||||
|
||||
stompClient.connect({}, function (frame) {
|
||||
console.log('Connected: ' + frame);
|
||||
connected = true;
|
||||
updateStatus('已连接', true);
|
||||
document.getElementById('sendButton').disabled = false;
|
||||
|
||||
// 订阅用户消息
|
||||
stompClient.subscribe('/user/queue/messages', function (message) {
|
||||
const messageData = JSON.parse(message.body);
|
||||
displayMessage(messageData);
|
||||
});
|
||||
|
||||
// 订阅广播消息
|
||||
stompClient.subscribe('/topic/broadcast', function (message) {
|
||||
const messageData = JSON.parse(message.body);
|
||||
displayMessage(messageData);
|
||||
});
|
||||
|
||||
// 发送连接消息
|
||||
stompClient.send("/app/chat.connect", {}, JSON.stringify({}));
|
||||
|
||||
}, function (error) {
|
||||
console.log('Connection error: ' + error);
|
||||
updateStatus('连接失败: ' + error, false);
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (stompClient !== null) {
|
||||
stompClient.send("/app/chat.disconnect", {}, JSON.stringify({}));
|
||||
stompClient.disconnect();
|
||||
}
|
||||
connected = false;
|
||||
updateStatus('已断开连接', false);
|
||||
document.getElementById('sendButton').disabled = true;
|
||||
console.log("Disconnected");
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const message = messageInput.value.trim();
|
||||
const userId = document.getElementById('userId').value || 'test-user';
|
||||
|
||||
if (message && connected) {
|
||||
const chatRequest = {
|
||||
content: message,
|
||||
senderId: userId,
|
||||
senderType: 'USER',
|
||||
messageType: 'TEXT',
|
||||
conversationId: 'test-conversation-' + userId
|
||||
};
|
||||
|
||||
stompClient.send("/app/chat.send", {}, JSON.stringify(chatRequest));
|
||||
messageInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function displayMessage(messageData) {
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'message';
|
||||
|
||||
// 根据发送者类型设置样式
|
||||
switch (messageData.senderType) {
|
||||
case 'USER':
|
||||
messageDiv.className += ' user';
|
||||
break;
|
||||
case 'AI':
|
||||
messageDiv.className += ' ai';
|
||||
break;
|
||||
case 'SYSTEM':
|
||||
messageDiv.className += ' system';
|
||||
break;
|
||||
default:
|
||||
messageDiv.className += ' system';
|
||||
}
|
||||
|
||||
// 根据消息类型设置样式
|
||||
if (messageData.type === 'ERROR') {
|
||||
messageDiv.className = 'message error';
|
||||
}
|
||||
|
||||
// 设置消息内容
|
||||
let content = messageData.content;
|
||||
if (messageData.createTime) {
|
||||
content += ' <small>(' + messageData.createTime + ')</small>';
|
||||
}
|
||||
|
||||
messageDiv.innerHTML = content;
|
||||
messagesDiv.appendChild(messageDiv);
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
|
||||
function updateStatus(message, isConnected) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = 'status ' + (isConnected ? 'connected' : 'disconnected');
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
document.getElementById('messages').innerHTML = '';
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后自动连接
|
||||
window.onload = function() {
|
||||
// 可以在这里自动连接
|
||||
// connect();
|
||||
};
|
||||
|
||||
// 页面关闭时断开连接
|
||||
window.onbeforeunload = function() {
|
||||
if (connected) {
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.emotionmuseum.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* WebSocket应用测试类
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class WebSocketTestApplication {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
// 测试Spring上下文是否能正常加载
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
enabled: false
|
||||
config:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum.websocket: DEBUG
|
||||
org.springframework.web.socket: DEBUG
|
||||
Reference in New Issue
Block a user