重构:从微服务架构迁移到单体架构

- 删除所有微服务模块(auth, ai, user, websocket, gateway等)
- 保留backend-single单体应用
- 修复管理员登录接口配置问题
- 添加AI接口配置表和初始化数据
- 修复前端代理配置,确保正确调用后端API
- 管理员登录功能已验证正常工作

主要变更:
1. 架构简化:单体应用更适合当前项目规模
2. 配置修复:解决了拦截器和密码验证问题
3. 前端配置:修复Vite代理配置指向正确的后端端口
4. 数据库:添加AI配置管理表,支持动态配置管理
This commit is contained in:
2025-10-30 10:11:15 +08:00
parent f7a3868f34
commit 806250aa8e
274 changed files with 0 additions and 25021 deletions
-148
View File
@@ -1,148 +0,0 @@
# Controller层重构总结
## 重构概述
本次重构主要完成了以下工作:
1. **创建统一的request和response包结构**
2. **建立全局异常处理机制**
3. **重构所有Controller层代码**
4. **优化接口入参和出参规范**
## 完成的工作
### 1. 创建统一的基础类
#### 在emotion-common模块中创建:
- `BaseRequest` - 基础请求类,包含通用字段如requestId、clientIp、userAgent等
- `BaseResponse` - 基础响应类,包含通用字段如timestamp、requestId、processingTime等
- `BasePageRequest` - 基础分页请求类,继承BaseRequest,包含分页参数
- `BasePageResponse` - 基础分页响应类,继承BaseResponse,包含分页信息
#### 异常处理类:
- `BusinessException` - 业务异常
- `AuthException` - 认证异常
- `CaptchaException` - 验证码异常
- `TokenException` - Token异常
- `GlobalExceptionHandler` - 全局异常处理器
### 2. 各模块request和response类
#### emotion-ai模块:
- **Request类**
- `AiChatRequest` - AI聊天请求
- `CreateConversationRequest` - 创建会话请求
- `EmotionAnalysisRequest` - 情绪分析请求
- `GuestChatRequest` - 访客聊天请求
- `ConversationListRequest` - 会话列表请求
- **Response类**
- `AiChatResponse` - AI聊天响应
- `CreateConversationResponse` - 创建会话响应
- `EmotionAnalysisResponse` - 情绪分析响应
- `GuestChatResponse` - 访客聊天响应
- `ConversationListResponse` - 会话列表响应
#### emotion-auth模块:
- **Request类**
- `LoginRequest` - 登录请求
- `RegisterRequest` - 注册请求
- `OAuthLoginRequest` - 第三方登录请求
- `SliderCaptchaVerifyRequest` - 滑块验证码验证请求
- **Response类**
- `LoginResponse` - 登录响应
- `UserInfoResponse` - 用户信息响应
- `CaptchaResponse` - 验证码响应
- `SliderCaptchaResponse` - 滑块验证码响应
#### emotion-user模块:
- **Request类**
- `UserUpdateRequest` - 用户更新请求
- **Response类**
- `UserInfoResponse` - 用户信息响应
#### emotion-record模块:
- **Request类**
- `CreateEmotionRecordRequest` - 创建情绪记录请求
- **Response类**
- `EmotionRecordResponse` - 情绪记录响应
### 3. Controller层重构
#### 重构原则:
1. **移除业务逻辑** - 所有业务逻辑移至Service层
2. **统一入参出参** - 使用新的request/response格式
3. **移除try-catch** - 使用全局异常处理机制
4. **统一返回格式** - 使用Result包装返回结果
#### 已重构的Controller
- `AiChatController` - AI聊天控制器
- `GuestChatController` - 访客聊天控制器
- `AuthController` - 认证控制器
- `CaptchaController` - 验证码控制器
- `UserController` - 用户控制器
### 4. 全局异常处理
#### 异常处理机制:
- 统一异常处理器 `GlobalExceptionHandler`
- 支持多种异常类型处理
- 自动参数校验异常处理
- 统一错误响应格式
#### 支持的异常类型:
- 业务异常 `BusinessException`
- 认证异常 `AuthException`
- 验证码异常 `CaptchaException`
- Token异常 `TokenException`
- 参数校验异常 `MethodArgumentNotValidException`
- 系统异常 `RuntimeException``Exception`
## 代码规范
### 1. 命名规范
- Request类以`Request`结尾
- Response类以`Response`结尾
- 包名使用`request``response`
### 2. 继承关系
- 所有Request类继承`BaseRequest``BasePageRequest`
- 所有Response类继承`BaseResponse``BasePageResponse`
### 3. 注解规范
- 使用`@Schema`注解描述字段
- 使用`@Valid`注解进行参数校验
- 使用`@NotBlank``@NotNull`等校验注解
### 4. Controller规范
- 不包含业务逻辑
- 统一使用Result包装返回结果
- 不使用try-catch,依赖全局异常处理
- 接口文档完整
## 优势
1. **代码结构清晰** - 职责分离明确
2. **异常处理统一** - 全局异常处理机制
3. **接口规范统一** - 统一的入参出参格式
4. **维护性提升** - 代码更易维护和扩展
5. **开发效率提升** - 减少重复代码
## 后续工作
1. **Service层接口更新** - 确保Service层使用新的request/response格式
2. **单元测试编写** - 为重构后的代码编写测试用例
3. **接口文档更新** - 更新API文档
4. **性能测试** - 验证重构后的性能表现
## 注意事项
1. 所有Controller层不再包含业务逻辑
2. 异常处理统一由GlobalExceptionHandler处理
3. 新的request/response类需要在Service层中使用
4. 需要更新相关的单元测试和集成测试
-364
View File
@@ -1,364 +0,0 @@
# 情绪博物馆后端微服务
基于Spring Cloud Alibaba 2022.0.0.0的微服务架构,为情绪博物馆iOS应用提供后端API服务。
## 🏗️ 架构概览
### 技术栈
- **Spring Boot**: 3.0.2
- **Spring Cloud**: 2022.0.0
- **Spring Cloud Alibaba**: 2022.0.0.0
- **JDK**: 17+
- **MySQL**: 8.0+
- **Redis**: 7.0+
- **Nacos**: 2.2.0+
### 微服务列表
| 服务名称 | 端口 | 描述 | 状态 |
|---------|------|------|------|
| emotion-gateway | 8080 | API网关 | ✅ 已实现 |
| emotion-user | 8081 | 用户服务 | ✅ 已实现 |
| emotion-ai | 8082 | AI对话服务 | ✅ 已实现 |
| emotion-record | 8083 | 情绪记录服务 | ✅ 已实现 |
| emotion-growth | 8084 | 成长课题服务 | ✅ 已实现 |
| emotion-explore | 8085 | 地图探索服务 | ✅ 已实现 |
| emotion-reward | 8086 | 成就奖励服务 | ✅ 已实现 |
| emotion-stats | 8087 | 统计分析服务 | ✅ 已实现 |
## 🚀 快速开始
### 环境要求
- JDK 17+
- Maven 3.6+
- MySQL 8.0+
- Redis 7.0+
- Nacos 2.2.0+
### 1. 环境准备
#### 启动Nacos
```bash
# 下载Nacos 2.2.0
wget https://github.com/alibaba/nacos/releases/download/2.2.0/nacos-server-2.2.0.tar.gz
tar -xzf nacos-server-2.2.0.tar.gz
cd nacos/bin
# 单机模式启动
sh startup.sh -m standalone
# 访问控制台: http://localhost:8848/nacos
# 默认用户名/密码: nacos/nacos
```
#### 启动MySQL
```bash
# 创建数据库
mysql -u root -p
CREATE DATABASE emotion_museum DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
# 导入数据库结构
mysql -u root -p emotion_museum < ../mysql_deploy_database.sql
```
#### 启动Redis
```bash
redis-server
```
### 2. 配置Nacos
访问 http://localhost:8848/nacos,创建以下配置:
#### 命名空间
- 命名空间ID: `emotion-dev`
- 命名空间名: `情绪博物馆开发环境`
#### 配置文件
**common-mysql.yml**
```yaml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
hikari:
minimum-idle: 5
maximum-pool-size: 20
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout: 30000
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_uuid
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
```
**common-redis.yml**
```yaml
spring:
data:
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: 3000ms
```
**coze-config.yml**
```yaml
coze:
base-url: https://api.coze.cn
api-key: your-coze-api-key
bot-id: your-bot-id
user-id: emotion-museum-user
timeout: 60
max-retries: 3
stream: false
model:
temperature: 0.7
max-tokens: 1000
top-p: 0.9
frequency-penalty: 0.0
presence-penalty: 0.0
```
### 3. 启动微服务
#### 方式一:使用启动脚本(推荐)
```bash
# 启动所有服务
./start-services.sh
# 停止所有服务
./stop-services.sh
```
#### 方式二:手动启动
```bash
# 编译项目
mvn clean compile -DskipTests
# 启动网关服务
cd emotion-gateway
mvn spring-boot:run &
# 启动用户服务
cd ../emotion-user
mvn spring-boot:run &
```
### 4. 验证服务状态
#### 方式一:使用测试脚本(推荐)
```bash
# 运行完整测试
./test-services.sh
```
#### 方式二:手动验证
```bash
# 健康检查
curl http://localhost:8080/actuator/health # 网关服务
curl http://localhost:8081/actuator/health # 用户服务
curl http://localhost:8082/actuator/health # AI对话服务
curl http://localhost:8083/actuator/health # 情绪记录服务
curl http://localhost:8084/actuator/health # 成长课题服务
curl http://localhost:8085/actuator/health # 地图探索服务
curl http://localhost:8086/actuator/health # 成就奖励服务
curl http://localhost:8087/actuator/health # 统计分析服务
# Nacos服务列表
curl http://localhost:8848/nacos/v1/ns/service/list?pageNo=1&pageSize=10
```
## 📡 API文档
### 用户服务API
#### 用户注册
```bash
curl -X POST http://localhost:8080/api/user/register \
-H "Content-Type: application/json" \
-d '{
"account": "test_user",
"password": "123456",
"username": "测试用户",
"email": "test@example.com",
"phone": "13800138000",
"nickname": "小测试"
}'
```
#### 用户登录
```bash
curl -X POST http://localhost:8080/api/user/login \
-H "Content-Type: application/json" \
-d '{
"account": "test_user",
"password": "123456"
}'
```
#### 获取用户信息
```bash
curl -X GET http://localhost:8080/api/user/info/{userId} \
-H "Authorization: Bearer {token}"
```
### Coze AI服务API
#### 健康检查
```bash
curl http://localhost:8080/api/ai/coze/health
```
#### 测试AI对话
```bash
curl -X POST http://localhost:8080/api/ai/coze/test/message \
-H "Content-Type: application/json" \
-d "message=你好,我今天感觉有点焦虑&userId=test_user"
```
#### 测试情绪分析
```bash
curl -X POST http://localhost:8080/api/ai/coze/test/emotion \
-H "Content-Type: application/json" \
-d "text=我今天心情很好,阳光明媚"
```
#### 测试完整对话流程
```bash
curl -X POST http://localhost:8080/api/ai/coze/test/full-chat \
-H "Content-Type: application/json" \
-d "userMessage=我最近工作压力很大,感觉很累&userId=test_user"
```
## 🔧 开发指南
### 项目结构
```
backend/
├── emotion-common/ # 公共模块
│ ├── src/main/java/
│ │ └── com/emotionmuseum/common/
│ │ ├── entity/ # 基础实体
│ │ ├── result/ # 统一响应
│ │ └── util/ # 工具类
├── emotion-gateway/ # 网关服务
├── emotion-user/ # 用户服务
├── emotion-ai/ # AI对话服务(待实现)
├── emotion-record/ # 情绪记录服务(待实现)
├── emotion-growth/ # 成长课题服务(待实现)
├── emotion-explore/ # 地图探索服务(待实现)
├── emotion-reward/ # 成就奖励服务(待实现)
├── emotion-stats/ # 统计分析服务(待实现)
├── start-services.sh # 启动脚本
├── stop-services.sh # 停止脚本
└── pom.xml # 父工程POM
```
### 添加新微服务
1. **创建模块**
```bash
mkdir emotion-new-service
cd emotion-new-service
```
2. **创建pom.xml**
```xml
<parent>
<groupId>com.emotionmuseum</groupId>
<artifactId>backend</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>emotion-new-service</artifactId>
```
3. **添加到父工程**
```xml
<modules>
<module>emotion-new-service</module>
</modules>
```
4. **创建启动类**
```java
@SpringBootApplication(scanBasePackages = {"com.emotionmuseum"})
@EnableDiscoveryClient
@MapperScan("com.emotionmuseum.newservice.mapper")
public class NewServiceApplication {
public static void main(String[] args) {
SpringApplication.run(NewServiceApplication.class, args);
}
}
```
## 🐛 故障排除
### 常见问题
1. **Nacos连接失败**
- 检查Nacos是否启动:`curl http://localhost:8848/nacos/v1/ns/operator/metrics`
- 检查命名空间是否创建
- 检查配置文件是否正确
2. **数据库连接失败**
- 检查MySQL是否启动:`mysqladmin ping`
- 检查数据库是否创建
- 检查用户名密码是否正确
3. **Redis连接失败**
- 检查Redis是否启动:`redis-cli ping`
- 检查端口是否正确
4. **服务启动失败**
- 查看日志文件:`tail -f logs/emotion-*.log`
- 检查端口是否被占用:`lsof -i :8080`
### 日志查看
```bash
# 查看所有服务日志
tail -f logs/*.log
# 查看特定服务日志
tail -f logs/emotion-user.log
```
## 📊 监控
### 健康检查端点
- 网关: http://localhost:8080/actuator/health
- 用户服务: http://localhost:8081/actuator/health
### Prometheus指标
- 网关: http://localhost:8080/actuator/prometheus
- 用户服务: http://localhost:8081/actuator/prometheus
## 🤝 贡献指南
1. Fork项目
2. 创建功能分支:`git checkout -b feature/new-feature`
3. 提交更改:`git commit -am 'Add new feature'`
4. 推送分支:`git push origin feature/new-feature`
5. 提交Pull Request
## 📄 许可证
本项目采用MIT许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
-25
View File
@@ -1,25 +0,0 @@
<?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>admin</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>admin-api</artifactId>
<name>admin-api</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
-18
View File
@@ -1,18 +0,0 @@
<?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>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>admin</artifactId>
<packaging>pom</packaging>
<modules>
<module>api</module>
<module>server</module>
</modules>
</project>
-38
View File
@@ -1,38 +0,0 @@
<?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>admin</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>admin-server</artifactId>
<name>admin</name>
<dependencies>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>
-48
View File
@@ -1,48 +0,0 @@
# AI服务Dockerfile
FROM openjdk:17-jdk-alpine
# 设置工作目录
WORKDIR /app
# 安装必要的工具
RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 复制Maven构建文件
COPY pom.xml ./
COPY emotion-common ./emotion-common
COPY emotion-ai ./emotion-ai
# 安装Maven
RUN apk add --no-cache maven
# 构建应用
RUN mvn clean package -DskipTests -pl emotion-ai -am
# 创建运行用户
RUN addgroup -g 1000 emotion && \
adduser -D -s /bin/sh -u 1000 -G emotion emotion
# 复制jar文件
RUN cp emotion-ai/target/emotion-ai-*.jar app.jar
# 设置文件权限
RUN chown -R emotion:emotion /app
# 切换到非root用户
USER emotion
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:19002/actuator/health || exit 1
# 暴露端口
EXPOSE 19002
# 启动命令
ENTRYPOINT ["java", "-jar", \
"-Xms512m", "-Xmx1024m", \
"-Djava.security.egd=file:/dev/./urandom", \
"-Dspring.profiles.active=local", \
"app.jar"]
-25
View File
@@ -1,25 +0,0 @@
<?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>ai</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>ai-api</artifactId>
<name>ai-api</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
@@ -1,14 +0,0 @@
package com.emotionmuseum.ai.api.client;
import com.emotionmuseum.common.result.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "ai")
public interface AiClient {
@GetMapping("/api/ai/chat/health")
Result<Boolean> health();
}
-226
View File
@@ -1,226 +0,0 @@
#!/bin/bash
# emotion-ai 单独部署脚本
# 作者: emotion-museum
# 日期: 2025-07-18
set -e
# 配置变量
SERVICE_NAME="emotion-ai"
SERVICE_PORT="19002"
REMOTE_HOST="'root@101.200.208.45'"
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@101.200.208.45' "echo 'Connection successful'" > /dev/null 2>&1; then
log_success "远程服务器连接正常"
else
log_error "无法连接到远程服务器 'root@101.200.208.45'"
exit 1
fi
}
# 构建服务
build_service() {
log_info "构建服务: $SERVICE_NAME"
# 构建父项目依赖
cd ..
mvn clean install -DskipTests -q
cd emotion-ai
# 构建当前服务
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@101.200.208.45' "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@101.200.208.45' "
mkdir -p $REMOTE_BUILD_DIR
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
mkdir -p /data/logs/emotion-museum
"
# 删除旧jar包
log_info "删除远程旧jar包"
ssh 'root@101.200.208.45' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
# 上传新jar包
log_info "上传jar包"
if scp "$jar_file" 'root@101.200.208.45':$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@101.200.208.45' "
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@101.200.208.45' "docker network create emotion-network 2>/dev/null || true"
# 构建镜像
log_info "构建Docker镜像"
ssh 'root@101.200.208.45' "
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@101.200.208.45' "
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=101.200.208.45 \\
-e MYSQL_PORT=3306 \\
-e MYSQL_DATABASE=emotion_museum \\
-e MYSQL_USERNAME=root \\
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
-e REDIS_HOST=101.200.208.45 \\
-e REDIS_PORT=6379 \\
-e REDIS_PASSWORD= \\
-e REDIS_DATABASE=0 \\
-e NACOS_SERVER_ADDR=101.200.208.45:8848 \\
-e NACOS_USERNAME=nacos \\
-e NACOS_PASSWORD='Peanut2817*#' \\
--restart unless-stopped \\
${PROJECT_NAME}/${SERVICE_NAME}:latest
"
# 等待启动
log_info "等待服务启动..."
sleep 15
# 检查状态
if ssh 'root@101.200.208.45' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
log_success "服务启动成功"
# 显示日志
log_info "服务日志 最后20行:"
ssh 'root@101.200.208.45' "docker logs --tail 20 ${SERVICE_NAME}"
# 健康检查
log_info "执行健康检查..."
sleep 10
if ssh 'root@101.200.208.45' "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@101.200.208.45' "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://101.200.208.45:$SERVICE_PORT"
}
# 执行主函数
main "$@"
-18
View File
@@ -1,18 +0,0 @@
<?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>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>ai</artifactId>
<packaging>pom</packaging>
<modules>
<module>api</module>
<module>server</module>
</modules>
</project>
-65
View File
@@ -1,65 +0,0 @@
<?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>ai</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>ai-server</artifactId>
<name>ai</name>
<description>AI对话服务</description>
<dependencies>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>ai-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
</project>
@@ -1,24 +0,0 @@
package com.emotionmuseum.ai;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* AI对话服务启动类
*
* @author huazhongmin
* @since 2025-07-12
*/
@SpringBootApplication(scanBasePackages = {"com.emotionmuseum"})
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan("com.emotionmuseum.ai.mapper")
public class AiApplication {
public static void main(String[] args) {
SpringApplication.run(AiApplication.class, args);
}
}
@@ -1,35 +0,0 @@
package com.emotionmuseum.ai.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
/**
* AI配置类
* 配置Coze平台HTTP客户端
*
* @author huazhongmin
* @since 2025-07-12
*/
@Configuration
public class AiConfig {
@Value("${coze.base-url:https://api.coze.cn}")
private String baseUrl;
@Value("${coze.token}")
private String token;
/**
* 配置Coze API客户端
*/
@Bean
public WebClient cozeWebClient() {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Authorization", "Bearer " + token)
.defaultHeader("Content-Type", "application/json")
.build();
}
}
@@ -1,53 +0,0 @@
package com.emotionmuseum.ai.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 功能开关配置
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
@Component
@ConfigurationProperties(prefix = "features")
public class FeatureConfig {
/**
* 情绪分析功能配置
*/
private EmotionAnalysis emotionAnalysis = new EmotionAnalysis();
/**
* 聊天功能配置
*/
private Chat chat = new Chat();
@Data
public static class EmotionAnalysis {
/**
* 是否启用情绪分析功能
*/
private boolean enabled = false;
/**
* 是否自动进行情绪分析
*/
private boolean autoAnalyze = false;
}
@Data
public static class Chat {
/**
* 是否启用聊天功能
*/
private boolean enabled = true;
/**
* 是否启用流式聊天
*/
private boolean stream = false;
}
}
@@ -1,235 +0,0 @@
package com.emotionmuseum.ai.controller;
import com.emotionmuseum.ai.request.*;
import com.emotionmuseum.ai.response.*;
import com.emotionmuseum.ai.service.AiChatService;
import com.emotionmuseum.ai.service.ConversationDbService;
import com.emotionmuseum.ai.entity.Conversation;
import com.emotionmuseum.ai.entity.Message;
import com.emotionmuseum.ai.dto.ChatRequest;
import com.emotionmuseum.ai.dto.ChatResponse;
import com.emotionmuseum.common.response.BasePageResponse;
import com.emotionmuseum.common.dto.PageQuery;
import com.emotionmuseum.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
/**
* AI聊天控制器
*
* @author huazhongmin
* @since 2025-07-12
*/
@Slf4j
@RestController
@RequestMapping("/api/ai/chat")
@RequiredArgsConstructor
@Validated
@Tag(name = "AI聊天", description = "AI聊天相关接口")
public class AiChatController {
private final AiChatService aiChatService;
private final ConversationDbService conversationDbService;
@Operation(summary = "创建会话")
@PostMapping("/conversation/create")
public Result<com.emotionmuseum.ai.dto.CreateConversationResponse> createConversation(
@Valid @RequestBody com.emotionmuseum.ai.dto.CreateConversationRequest request) {
log.info("收到创建会话请求: userId={}, title={}", request.getUserId(), request.getTitle());
com.emotionmuseum.ai.dto.CreateConversationResponse response = aiChatService.createConversation(request);
return Result.success(response);
}
@Operation(summary = "发送聊天消息")
@PostMapping("/send")
public Result<com.emotionmuseum.ai.dto.ChatResponse> sendMessage(
@Valid @RequestBody com.emotionmuseum.ai.dto.ChatRequest request) {
log.info("收到聊天请求: userId={}, message={}", request.getUserId(), request.getMessage());
com.emotionmuseum.ai.dto.ChatResponse response = aiChatService.chat(request);
return Result.success(response);
}
@Operation(summary = "情绪分析")
@PostMapping("/emotion/analyze")
public Result<com.emotionmuseum.ai.dto.EmotionAnalysisResponse> analyzeEmotion(
@Valid @RequestBody com.emotionmuseum.ai.dto.EmotionAnalysisRequest request) {
log.info("收到情绪分析请求: userId={}, text={}", request.getUserId(), request.getText());
com.emotionmuseum.ai.dto.EmotionAnalysisResponse response = aiChatService.analyzeEmotion(request);
return Result.success(response);
}
@Operation(summary = "流式聊天")
@PostMapping("/stream")
public Result<String> streamChat(@Valid @RequestBody com.emotionmuseum.ai.dto.ChatRequest request) {
log.info("收到流式聊天请求: userId={}", request.getUserId());
String response = aiChatService.streamChat(request);
return Result.success(response);
}
@Operation(summary = "健康检查")
@GetMapping("/health")
public Result<Boolean> healthCheck() {
log.info("AI服务健康检查");
boolean isHealthy = aiChatService.healthCheck();
return Result.success(isHealthy);
}
@Operation(summary = "获取AI服务信息")
@GetMapping("/info")
public Result<String> getServiceInfo() {
log.info("获取AI服务信息");
return Result.success("Emotion Museum AI Service - Powered by Spring AI & Coze");
}
@Operation(summary = "获取用户会话列表")
@GetMapping("/conversations/{userId}")
public Result<List<Conversation>> getUserConversations(
@Parameter(description = "用户ID") @PathVariable String userId,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum,
@Parameter(description = "页大小") @RequestParam(defaultValue = "20") Integer pageSize) {
log.info("获取用户会话列表: userId={}, pageNum={}, pageSize={}", userId, pageNum, pageSize);
PageQuery pageQuery = new PageQuery();
pageQuery.setPageNum(pageNum);
pageQuery.setPageSize(pageSize);
List<Conversation> conversations = conversationDbService.getConversationsByUserId(userId, pageQuery);
return Result.success(conversations);
}
@Operation(summary = "获取会话详情")
@GetMapping("/conversation/{conversationId}")
public Result<Conversation> getConversation(@Parameter(description = "会话ID") @PathVariable String conversationId) {
log.info("获取会话详情: conversationId={}", conversationId);
Conversation conversation = conversationDbService.getConversationById(conversationId);
return Result.success(conversation);
}
@Operation(summary = "获取会话消息列表")
@GetMapping("/conversation/{conversationId}/messages")
public Result<List<Message>> getConversationMessages(
@Parameter(description = "会话ID") @PathVariable String conversationId,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum,
@Parameter(description = "页大小") @RequestParam(defaultValue = "50") Integer pageSize) {
log.info("获取会话消息列表: conversationId={}, pageNum={}, pageSize={}", conversationId, pageNum, pageSize);
PageQuery pageQuery = new PageQuery();
pageQuery.setPageNum(pageNum);
pageQuery.setPageSize(pageSize);
List<Message> messages = conversationDbService.getMessagesByConversationId(conversationId, pageQuery);
return Result.success(messages);
}
@Operation(summary = "结束会话")
@PutMapping("/conversation/{conversationId}/end")
public Result<Void> endConversation(@Parameter(description = "会话ID") @PathVariable String conversationId) {
log.info("结束会话: conversationId={}", conversationId);
boolean success = conversationDbService.updateConversationStatus(conversationId, "ended");
return success ? Result.success() : Result.error("结束会话失败");
}
@Operation(summary = "删除会话")
@DeleteMapping("/conversation/{conversationId}")
public Result<Void> deleteConversation(@Parameter(description = "会话ID") @PathVariable String conversationId) {
log.info("删除会话: conversationId={}", conversationId);
boolean success = conversationDbService.deleteConversation(conversationId);
return success ? Result.success() : Result.error("删除会话失败");
}
@Operation(summary = "标记消息已读")
@PutMapping("/message/{messageId}/read")
public Result<Void> markMessageAsRead(@Parameter(description = "消息ID") @PathVariable String messageId) {
log.info("标记消息已读: messageId={}", messageId);
boolean success = conversationDbService.markMessageAsRead(messageId);
return success ? Result.success() : Result.error("标记消息已读失败");
}
@Operation(summary = "标记会话所有消息已读")
@PutMapping("/conversation/{conversationId}/read")
public Result<Void> markConversationMessagesAsRead(
@Parameter(description = "会话ID") @PathVariable String conversationId) {
log.info("标记会话消息已读: conversationId={}", conversationId);
boolean success = conversationDbService.markConversationMessagesAsRead(conversationId);
return success ? Result.success() : Result.error("标记会话消息已读失败");
}
@Operation(summary = "获取拆分后的消息详情")
@GetMapping("/messages/split")
public Result<List<Message>> getSplitMessages(
@Parameter(description = "消息ID列表,逗号分隔") @RequestParam String messageIds) {
log.info("获取拆分消息详情: messageIds={}", messageIds);
String[] ids = messageIds.split(",");
List<Message> messages = conversationDbService.getMessagesByIds(List.of(ids));
return Result.success(messages);
}
@Operation(summary = "测试消息拆分功能")
@PostMapping("/test/split")
public Result<ChatResponse> testMessageSplit(@Valid @RequestBody ChatRequest request) {
log.info("测试消息拆分功能: userId={}, message={}", request.getUserId(), request.getMessage());
// 模拟一个包含\n\n的AI回复
String mockAiReply = "这是第一段回复,介绍了基本功能。我可以帮助你进行日常对话。\n\n" +
"这是第二段回复,详细说明了聊天功能。我能理解你的情感并给出合适的回应。\n\n" +
"这是第三段回复,介绍了情感分析功能。我可以分析你的情绪状态并提供建议。";
// 创建或获取会话
com.emotionmuseum.ai.dto.CreateConversationRequest convRequest = new com.emotionmuseum.ai.dto.CreateConversationRequest();
convRequest.setUserId(request.getUserId());
convRequest.setTitle("测试拆分消息");
com.emotionmuseum.ai.dto.CreateConversationResponse conversation = aiChatService
.createConversation(convRequest);
// 保存用户消息
Message userMessage = new Message();
userMessage.setConversationId(conversation.getConversationId());
userMessage.setContent(request.getMessage());
userMessage.setType("text");
userMessage.setSender("user");
userMessage.setTimestamp(java.time.LocalDateTime.now());
userMessage.setStatus("sent");
userMessage.setIsRead(0);
Message savedUserMessage = conversationDbService.saveMessage(userMessage);
// 使用拆分逻辑保存AI回复
List<Message> savedAiMessages = aiChatService.saveAiReplyMessages(
conversation.getConversationId(), mockAiReply, null);
// 构建响应
ChatResponse response = new ChatResponse();
Message lastMessage = savedAiMessages.get(savedAiMessages.size() - 1);
response.setMessageId(lastMessage.getId());
response.setConversationId(conversation.getConversationId());
response.setContent(mockAiReply);
response.setTimestamp(lastMessage.getTimestamp());
// 设置多条消息信息
if (savedAiMessages.size() > 1) {
response.setMultipleMessages(true);
response.setMessageCount(savedAiMessages.size());
response.setMessageIds(savedAiMessages.stream()
.map(Message::getId)
.collect(java.util.stream.Collectors.toList()));
} else {
response.setMultipleMessages(false);
response.setMessageCount(1);
}
return Result.success(response);
}
}
@@ -1,182 +0,0 @@
package com.emotionmuseum.ai.controller;
import com.emotionmuseum.ai.dto.GuestChatRequest;
import com.emotionmuseum.ai.dto.GuestChatResponse;
import com.emotionmuseum.ai.dto.GuestUserInfo;
import com.emotionmuseum.ai.dto.MessageListResponse;
import com.emotionmuseum.ai.dto.ConversationListResponse;
import com.emotionmuseum.ai.service.GuestChatService;
import com.emotionmuseum.common.interceptor.UserContextInterceptor;
import com.emotionmuseum.common.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.List;
/**
* 访客聊天控制器
* 提供访客模式下的聊天功能
*
* @author huazhongmin
* @since 2025-07-24
*/
@Slf4j
@RestController
@RequestMapping("/ai/guest")
@RequiredArgsConstructor
@Tag(name = "访客聊天", description = "访客模式下的AI聊天功能")
public class GuestChatController {
private final GuestChatService guestChatService;
private final UserContextInterceptor userContextInterceptor = new UserContextInterceptor();
@PostMapping("/chat")
@Operation(summary = "访客聊天", description = "访客模式下发送消息并获取AI回复")
public Result<GuestChatResponse> guestChat(
@RequestBody GuestChatRequest request) {
// 自动获取客户端IP和User-Agent
String clientIp = getClientIp();
String userAgent = getUserAgent();
request.setClientIp(clientIp);
request.setUserAgent(userAgent);
log.info("访客聊天请求: IP={}, Message={}", clientIp, request.getMessage());
return guestChatService.guestChat(request);
}
@GetMapping("/conversations")
@Operation(summary = "获取访客会话列表", description = "根据IP地址获取访客的历史会话列表")
public Result<List<ConversationListResponse>> getGuestConversations(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum,
@Parameter(description = "页大小") @RequestParam(defaultValue = "20") Integer pageSize) {
String clientIp = getClientIp();
log.info("获取访客会话列表: IP={}", clientIp);
return (Result) guestChatService.getGuestConversations(clientIp, pageNum, pageSize);
}
@GetMapping("/messages/{conversationId}")
@Operation(summary = "获取会话消息", description = "获取指定会话的所有消息")
public Result<List<MessageListResponse>> getGuestConversationMessages(
@Parameter(description = "会话ID") @PathVariable String conversationId,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum,
@Parameter(description = "页大小") @RequestParam(defaultValue = "50") Integer pageSize) {
String clientIp = getClientIp();
log.info("获取访客会话消息: IP={}, ConversationId={}", clientIp, conversationId);
return (Result) guestChatService.getGuestConversationMessages(conversationId, clientIp, pageNum, pageSize);
}
@PostMapping("/end/{conversationId}")
@Operation(summary = "结束会话", description = "结束指定的访客会话")
public Result<Void> endGuestConversation(
@Parameter(description = "会话ID") @PathVariable String conversationId) {
String clientIp = getClientIp();
log.info("结束访客会话: IP={}, ConversationId={}", clientIp, conversationId);
return guestChatService.endGuestConversation(conversationId, clientIp);
}
@GetMapping("/user-info")
@Operation(summary = "获取访客用户信息", description = "根据IP地址获取或创建访客用户信息")
public Result<GuestUserInfo> getGuestUserInfo() {
String clientIp = getClientIp();
String userAgent = getUserAgent();
log.info("获取访客用户信息: IP={}", clientIp);
return guestChatService.getGuestUserInfo(clientIp, userAgent);
}
@PostMapping("/test-split")
@Operation(summary = "测试拆分功能", description = "测试AI回复的拆分功能")
public Result<GuestChatResponse> testSplitFunction(
@RequestBody GuestChatRequest request) {
log.info("测试拆分功能: Message={}", request.getMessage());
// 根据消息内容生成不同的模拟回复
String mockAiReply;
if (request.getMessage().contains("双换行")) {
mockAiReply = "这是第一段回复,介绍基本功能。\n\n" +
"这是第二段回复,说明聊天功能。\n\n" +
"这是第三段回复,介绍情感分析。\n\n" +
"这是第四段回复,提供使用建议。";
} else if (request.getMessage().contains("单换行")) {
mockAiReply = "这是第一行回复,介绍基本功能。\n" +
"这是第二行回复,说明聊天功能。\n" +
"这是第三行回复,介绍情感分析。\n" +
"这是第四行回复,提供使用建议。";
} else {
mockAiReply = "这是一个完整的回复,没有换行符,将作为单条消息处理。包含了所有功能介绍和使用说明。";
}
// 创建模拟的访客聊天响应
GuestChatResponse response = new GuestChatResponse();
response.setGuestUserId("test_guest_user");
response.setGuestNickname("测试用户");
response.setConversationId("test_conversation_" + System.currentTimeMillis());
response.setUserMessage(request.getMessage());
response.setAiReply(mockAiReply);
response.setTimestamp(LocalDateTime.now());
response.setConversationStatus("active");
response.setIsNewConversation(true);
log.info("测试拆分功能完成,AI回复长度: {}, 包含\\n\\n: {}, 包含\\n: {}",
mockAiReply.length(),
mockAiReply.contains("\n\n"),
mockAiReply.contains("\n"));
return Result.success(response);
}
/**
* 获取客户端真实IP地址
*/
private String getClientIp() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes == null) {
return "127.0.0.1";
}
var request = attributes.getRequest();
return userContextInterceptor.getClientIpAddress(request);
} catch (Exception e) {
return "127.0.0.1";
}
}
/**
* 获取用户代理信息
*/
private String getUserAgent() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes == null) {
return "Unknown";
}
var request = attributes.getRequest();
return request.getHeader("User-Agent");
} catch (Exception e) {
return "Unknown";
}
}
}
@@ -1,59 +0,0 @@
package com.emotionmuseum.ai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* 聊天请求
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "聊天请求")
public class ChatRequest {
@Schema(description = "用户ID", example = "user_123")
@NotBlank(message = "用户ID不能为空")
private String userId;
@Schema(description = "消息内容", example = "我今天感觉有点焦虑,不知道该怎么办")
@NotBlank(message = "消息内容不能为空")
@Size(max = 2000, message = "消息内容不能超过2000字符")
private String message;
@Schema(description = "对话ID(可选)", example = "conv_123456")
private String conversationId;
@Schema(description = "消息类型", example = "text")
private String type = "text";
@Schema(description = "聊天历史(可选)")
private List<ChatMessage> history;
@Schema(description = "是否需要情绪分析", example = "true")
private Boolean needEmotionAnalysis = true;
@Schema(description = "上下文信息")
private String context;
/**
* 聊天消息
*/
@Data
@Schema(description = "聊天消息")
public static class ChatMessage {
@Schema(description = "角色", example = "user")
private String role; // user, assistant
@Schema(description = "消息内容")
private String content;
@Schema(description = "时间戳")
private Long timestamp;
}
}
@@ -1,73 +0,0 @@
package com.emotionmuseum.ai.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 聊天响应
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "聊天响应")
public class ChatResponse {
@Schema(description = "消息ID")
private String messageId;
@Schema(description = "对话ID")
private String conversationId;
@Schema(description = "AI回复内容")
private String content;
@Schema(description = "消息类型", example = "text")
private String type = "text";
@Schema(description = "发送者", example = "assistant")
private String sender = "assistant";
@Schema(description = "响应时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timestamp;
@Schema(description = "情绪分析结果")
private EmotionAnalysisResponse emotionAnalysis;
@Schema(description = "使用情况")
private Usage usage;
@Schema(description = "元数据")
private Map<String, Object> metadata;
@Schema(description = "是否为多条消息")
private Boolean multipleMessages = false;
@Schema(description = "消息数量")
private Integer messageCount = 1;
@Schema(description = "所有消息ID列表(当拆分为多条消息时)")
private List<String> messageIds;
/**
* 使用情况
*/
@Data
@Schema(description = "使用情况")
public static class Usage {
@Schema(description = "输入Token数")
private Integer promptTokens;
@Schema(description = "输出Token数")
private Integer completionTokens;
@Schema(description = "总Token数")
private Integer totalTokens;
}
}
@@ -1,81 +0,0 @@
package com.emotionmuseum.ai.dto;
import lombok.Data;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 会话列表响应DTO
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationListResponse {
/**
* 会话ID
*/
private String conversationId;
/**
* 会话标题
*/
private String title;
/**
* 会话类型
*/
private String type;
/**
* 会话状态
*/
private String status;
/**
* 用户ID
*/
private String userId;
/**
* 用户类型
*/
private String userType;
/**
* 消息数量
*/
private Integer messageCount;
/**
* 最后活跃时间
*/
private LocalDateTime lastActiveTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 主要情绪
*/
private String primaryEmotion;
/**
* 情绪强度
*/
private Double emotionIntensity;
/**
* Coze会话ID
*/
private String cozeConversationId;
}
@@ -1,33 +0,0 @@
package com.emotionmuseum.ai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 创建会话请求
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "创建会话请求")
public class CreateConversationRequest {
@Schema(description = "用户ID", example = "user_123")
@NotBlank(message = "用户ID不能为空")
private String userId;
@Schema(description = "会话标题", example = "今日心情分享")
private String title;
@Schema(description = "会话类型", example = "emotion_chat")
private String type = "emotion_chat";
@Schema(description = "初始消息", example = "你好,我想聊聊今天的心情")
private String initialMessage;
@Schema(description = "上下文信息")
private String context;
}
@@ -1,48 +0,0 @@
package com.emotionmuseum.ai.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 创建会话响应
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "创建会话响应")
public class CreateConversationResponse {
@Schema(description = "会话ID")
private String conversationId;
@Schema(description = "用户ID")
private String userId;
@Schema(description = "会话标题")
private String title;
@Schema(description = "会话类型")
private String type;
@Schema(description = "会话状态", example = "active")
private String status = "active";
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
@Schema(description = "Coze会话ID")
private String cozeConversationId;
@Schema(description = "元数据")
private Map<String, Object> metadata;
}
@@ -1,36 +0,0 @@
package com.emotionmuseum.ai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 情绪分析请求
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "情绪分析请求")
public class EmotionAnalysisRequest {
@Schema(description = "用户ID", example = "user_123")
@NotBlank(message = "用户ID不能为空")
private String userId;
@Schema(description = "待分析文本", example = "我今天感觉很沮丧,工作压力很大")
@NotBlank(message = "待分析文本不能为空")
@Size(max = 1000, message = "待分析文本不能超过1000字符")
private String text;
@Schema(description = "分析类型", example = "detailed")
private String analysisType = "detailed"; // simple, detailed
@Schema(description = "语言", example = "zh")
private String language = "zh";
@Schema(description = "上下文信息")
private String context;
}
@@ -1,64 +0,0 @@
package com.emotionmuseum.ai.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 情绪分析响应
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "情绪分析响应")
public class EmotionAnalysisResponse {
@Schema(description = "主要情绪", example = "焦虑")
private String primaryEmotion;
@Schema(description = "情绪强度", example = "0.75")
private Double intensity;
@Schema(description = "情绪极性", example = "negative")
private String polarity; // positive, negative, neutral
@Schema(description = "置信度", example = "0.85")
private Double confidence;
@Schema(description = "情绪分布")
private List<EmotionScore> emotions;
@Schema(description = "关键词")
private List<String> keywords;
@Schema(description = "建议")
private String suggestion;
@Schema(description = "分析时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime analysisTime;
@Schema(description = "额外信息")
private Map<String, Object> metadata;
/**
* 情绪得分
*/
@Data
@Schema(description = "情绪得分")
public static class EmotionScore {
@Schema(description = "情绪名称")
private String emotion;
@Schema(description = "得分")
private Double score;
@Schema(description = "描述")
private String description;
}
}
@@ -1,53 +0,0 @@
package com.emotionmuseum.ai.dto;
import lombok.Data;
/**
* 访客聊天请求DTO
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
public class GuestChatRequest {
/**
* 消息内容
*/
private String message;
/**
* 会话ID (可选,如果不提供则创建新会话)
*/
private String conversationId;
/**
* 会话标题 (创建新会话时使用)
*/
private String title;
/**
* 客户端IP地址
*/
private String clientIp;
/**
* 用户代理信息
*/
private String userAgent;
/**
* 消息类型 (默认为text)
*/
private String messageType = "text";
/**
* 是否流式响应
*/
private Boolean stream = false;
/**
* 附加上下文信息
*/
private String context;
}
@@ -1,123 +0,0 @@
package com.emotionmuseum.ai.dto;
import lombok.Data;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 访客聊天响应DTO
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GuestChatResponse {
/**
* 访客用户ID
*/
private String guestUserId;
/**
* 访客昵称
*/
private String guestNickname;
/**
* 会话ID
*/
private String conversationId;
/**
* 会话标题
*/
private String conversationTitle;
/**
* 用户消息ID
*/
private String userMessageId;
/**
* AI回复消息ID
*/
private String aiMessageId;
/**
* 用户消息内容
*/
private String userMessage;
/**
* AI回复内容
*/
private String aiReply;
/**
* 消息时间戳
*/
private LocalDateTime timestamp;
/**
* 会话状态
*/
private String conversationStatus;
/**
* 是否为新会话
*/
private Boolean isNewConversation;
/**
* Coze聊天ID
*/
private String cozeChatId;
/**
* 情绪分析结果
*/
private EmotionAnalysisResult emotionAnalysis;
/**
* Token使用情况
*/
private TokenUsage tokenUsage;
/**
* 错误信息 (如果有)
*/
private String errorMessage;
/**
* 情绪分析结果内部类
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class EmotionAnalysisResult {
private String primaryEmotion;
private Double emotionScore;
private Double confidence;
private String emotionTrend;
}
/**
* Token使用情况内部类
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TokenUsage {
private Integer promptTokens;
private Integer completionTokens;
private Integer totalTokens;
}
}
@@ -1,71 +0,0 @@
package com.emotionmuseum.ai.dto;
import lombok.Data;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 访客用户信息DTO
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GuestUserInfo {
/**
* 访客用户ID (格式: guest_xxx)
*/
private String guestUserId;
/**
* 客户端IP地址
*/
private String ipAddress;
/**
* 用户代理信息
*/
private String userAgent;
/**
* 访客昵称
*/
private String nickname;
/**
* 访客头像
*/
private String avatar;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 最后活跃时间
*/
private LocalDateTime lastActiveTime;
/**
* 是否为访客用户
*/
private Boolean isGuest;
/**
* 会话数量
*/
private Integer conversationCount;
/**
* 消息数量
*/
private Integer messageCount;
}
@@ -1,97 +0,0 @@
package com.emotionmuseum.ai.dto;
import lombok.Data;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 消息列表响应DTO
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MessageListResponse {
/**
* 消息ID
*/
private String messageId;
/**
* 会话ID
*/
private String conversationId;
/**
* 消息内容
*/
private String content;
/**
* 消息类型
*/
private String type;
/**
* 发送者
*/
private String sender;
/**
* 时间戳
*/
private LocalDateTime timestamp;
/**
* 消息状态
*/
private String status;
/**
* 情绪类型
*/
private String emotionType;
/**
* 情绪分数
*/
private BigDecimal emotionScore;
/**
* 情绪置信度
*/
private BigDecimal emotionConfidence;
/**
* 是否已读
*/
private Integer isRead;
/**
* Coze聊天ID
*/
private String cozeChatId;
/**
* Coze消息ID
*/
private String cozeMessageId;
/**
* 用户ID
*/
private String userId;
/**
* 用户类型
*/
private String userType;
}
@@ -1,185 +0,0 @@
package com.emotionmuseum.ai.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.emotionmuseum.common.entity.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 对话实体
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "conversation", autoResultMap = true)
public class Conversation extends BaseEntity {
/**
* 用户ID (注册用户ID或访客用户ID)
*/
@TableField("user_id")
private String userId;
/**
* 用户类型 (registered: 注册用户, guest: 访客用户)
*/
@TableField("user_type")
private String userType;
/**
* 对话标题
*/
@TableField("title")
private String title;
/**
* 会话类型
*/
@TableField("type")
private String type;
/**
* 会话状态 (active, ended, archived)
*/
@TableField("status")
private String status;
/**
* Coze会话ID
*/
@TableField("coze_conversation_id")
private String cozeConversationId;
/**
* Bot ID
*/
@TableField("bot_id")
private String botId;
/**
* Workflow ID
*/
@TableField("workflow_id")
private String workflowId;
/**
* 初始消息
*/
@TableField("initial_message")
private String initialMessage;
/**
* 上下文信息
*/
@TableField("context")
private String context;
/**
* 开始时间
*/
@TableField("start_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
/**
* 结束时间
*/
@TableField("end_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
/**
* 最后活跃时间
*/
@TableField("last_active_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastActiveTime;
/**
* 对话摘要
*/
@TableField("summary")
private String summary;
/**
* 标签
*/
@TableField(value = "tags", typeHandler = JacksonTypeHandler.class)
private List<String> tags;
/**
* 主要情绪
*/
@TableField("primary_emotion")
private String primaryEmotion;
/**
* 情绪强度
*/
@TableField("emotion_intensity")
private BigDecimal emotionIntensity;
/**
* 情绪趋势
*/
@TableField("emotion_trend")
private String emotionTrend;
/**
* 关键词
*/
@TableField(value = "keywords", typeHandler = JacksonTypeHandler.class)
private List<String> keywords;
/**
* AI洞察
*/
@TableField("ai_insights")
private String aiInsights;
/**
* 分析置信度
*/
@TableField("confidence")
private BigDecimal confidence;
/**
* 消息数量
*/
@TableField("message_count")
private Integer messageCount;
/**
* 总Token使用量
*/
@TableField("total_tokens")
private Integer totalTokens;
/**
* 总费用
*/
@TableField("total_cost")
private BigDecimal totalCost;
/**
* 客户端IP地址 (用于访客用户)
*/
@TableField("client_ip")
private String clientIp;
/**
* 用户代理信息
*/
@TableField("user_agent")
private String userAgent;
}
@@ -1,263 +0,0 @@
package com.emotionmuseum.ai.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.emotionmuseum.common.entity.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
/**
* Coze API调用记录实体
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "coze_api_call", autoResultMap = true)
public class CozeApiCall extends BaseEntity {
/**
* 对话ID
*/
@TableField("conversation_id")
private String conversationId;
/**
* 消息ID
*/
@TableField("message_id")
private String messageId;
/**
* Coze聊天ID
*/
@TableField("coze_chat_id")
private String cozeChatId;
/**
* Coze对话ID
*/
@TableField("coze_conversation_id")
private String cozeConversationId;
/**
* Bot ID
*/
@TableField("bot_id")
private String botId;
/**
* Workflow ID
*/
@TableField("workflow_id")
private String workflowId;
/**
* 用户ID
*/
@TableField("user_id")
private String userId;
/**
* 请求类型:chat/stream/retrieve/messages
*/
@TableField("request_type")
private String requestType;
/**
* 用户消息内容
*/
@TableField("user_message")
private String userMessage;
/**
* 用户消息类型:text/image/file
*/
@TableField("user_message_type")
private String userMessageType;
/**
* AI回复内容
*/
@TableField("ai_reply")
private String aiReply;
/**
* AI回复类型:text/image/file
*/
@TableField("ai_reply_type")
private String aiReplyType;
/**
* 请求URL
*/
@TableField("request_url")
private String requestUrl;
/**
* 请求体
*/
@TableField(value = "request_body", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> requestBody;
/**
* 请求头
*/
@TableField(value = "request_headers", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> requestHeaders;
/**
* HTTP状态码
*/
@TableField("response_status")
private Integer responseStatus;
/**
* 响应体
*/
@TableField(value = "response_body", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> responseBody;
/**
* 响应头
*/
@TableField(value = "response_headers", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> responseHeaders;
/**
* 轮询次数
*/
@TableField("poll_count")
private Integer pollCount;
/**
* 轮询开始时间
*/
@TableField("poll_start_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime pollStartTime;
/**
* 轮询结束时间
*/
@TableField("poll_end_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime pollEndTime;
/**
* 最终状态:completed/failed/timeout
*/
@TableField("final_status")
private String finalStatus;
/**
* 调用状态:pending/success/failed/timeout
*/
@TableField("status")
private String status;
/**
* 开始时间
*/
@TableField("start_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
/**
* 结束时间
*/
@TableField("end_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
/**
* 耗时(毫秒)
*/
@TableField("duration_ms")
private Integer durationMs;
/**
* 输入Token数
*/
@TableField("prompt_tokens")
private Integer promptTokens;
/**
* 输出Token数
*/
@TableField("completion_tokens")
private Integer completionTokens;
/**
* 总Token数
*/
@TableField("total_tokens")
private Integer totalTokens;
/**
* 费用
*/
@TableField("cost")
private BigDecimal cost;
/**
* 函数调用记录
*/
@TableField(value = "function_calls", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> functionCalls;
/**
* 函数调用结果
*/
@TableField(value = "function_results", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> functionResults;
/**
* 错误代码
*/
@TableField("error_code")
private String errorCode;
/**
* 错误信息
*/
@TableField("error_message")
private String errorMessage;
/**
* 客户端IP
*/
@TableField("client_ip")
private String clientIp;
/**
* 用户代理
*/
@TableField("user_agent")
private String userAgent;
/**
* 会话ID
*/
@TableField("session_id")
private String sessionId;
/**
* 追踪ID
*/
@TableField("trace_id")
private String traceId;
/**
* 扩展元数据
*/
@TableField(value = "metadata", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> metadata;
}
@@ -1,99 +0,0 @@
package com.emotionmuseum.ai.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.emotionmuseum.common.entity.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 情绪分析实体
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "emotion_analysis", autoResultMap = true)
public class EmotionAnalysis extends BaseEntity {
/**
* 用户ID
*/
@TableField("user_id")
private String userId;
/**
* 消息ID
*/
@TableField("message_id")
private String messageId;
/**
* 分析文本
*/
@TableField("text")
private String text;
/**
* 主要情绪
*/
@TableField("primary_emotion")
private String primaryEmotion;
/**
* 情绪强度
*/
@TableField("intensity")
private BigDecimal intensity;
/**
* 情绪极性
*/
@TableField("polarity")
private String polarity;
/**
* 分析置信度
*/
@TableField("confidence")
private BigDecimal confidence;
/**
* 情绪详情
*/
@TableField(value = "emotions", typeHandler = JacksonTypeHandler.class)
private List<Map<String, Object>> emotions;
/**
* 关键词
*/
@TableField(value = "keywords", typeHandler = JacksonTypeHandler.class)
private List<String> keywords;
/**
* 建议
*/
@TableField("suggestion")
private String suggestion;
/**
* 分析时间
*/
@TableField("analysis_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime analysisTime;
/**
* 元数据
*/
@TableField(value = "metadata", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> metadata;
}
@@ -1,83 +0,0 @@
package com.emotionmuseum.ai.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotionmuseum.common.entity.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 访客用户实体
*
* @author huazhongmin
* @since 2025-07-13
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("guest_user")
public class GuestUser extends BaseEntity {
/**
* 访客用户ID (格式: guest_xxx)
*/
@TableField("guest_user_id")
private String guestUserId;
/**
* 客户端IP地址
*/
@TableField("ip_address")
private String ipAddress;
/**
* 用户代理信息
*/
@TableField("user_agent")
private String userAgent;
/**
* 访客昵称
*/
@TableField("nickname")
private String nickname;
/**
* 访客头像
*/
@TableField("avatar")
private String avatar;
/**
* 最后活跃时间
*/
@TableField("last_active_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastActiveTime;
/**
* 会话数量
*/
@TableField("conversation_count")
private Integer conversationCount;
/**
* 消息数量
*/
@TableField("message_count")
private Integer messageCount;
/**
* IP地址的地理位置信息
*/
@TableField("location")
private String location;
/**
* 设备信息
*/
@TableField("device_info")
private String deviceInfo;
}
@@ -1,164 +0,0 @@
package com.emotionmuseum.ai.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.emotionmuseum.common.entity.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 消息实体
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "message", autoResultMap = true)
public class Message extends BaseEntity {
/**
* 对话ID
*/
@TableField("conversation_id")
private String conversationId;
/**
* 消息内容
*/
@TableField("content")
private String content;
/**
* 消息类型:text/voice/image/system
*/
@TableField("type")
private String type;
/**
* 发送者:user/ai
*/
@TableField("sender")
private String sender;
/**
* 时间戳
*/
@TableField("timestamp")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timestamp;
/**
* Coze聊天ID
*/
@TableField("coze_chat_id")
private String cozeChatId;
/**
* Coze消息ID
*/
@TableField("coze_message_id")
private String cozeMessageId;
/**
* 消息状态:sending/sent/failed/processing
*/
@TableField("status")
private String status;
/**
* 错误信息
*/
@TableField("error_message")
private String errorMessage;
/**
* 情绪分数
*/
@TableField("emotion_score")
private BigDecimal emotionScore;
/**
* 情绪类型
*/
@TableField("emotion_type")
private String emotionType;
/**
* 情绪分析置信度
*/
@TableField("emotion_confidence")
private BigDecimal emotionConfidence;
/**
* 输入Token数
*/
@TableField("prompt_tokens")
private Integer promptTokens;
/**
* 输出Token数
*/
@TableField("completion_tokens")
private Integer completionTokens;
/**
* 总Token数
*/
@TableField("total_tokens")
private Integer totalTokens;
/**
* API调用费用
*/
@TableField("api_cost")
private BigDecimal apiCost;
/**
* 是否已读:0/1
*/
@TableField("is_read")
private Integer isRead;
/**
* 父消息ID(用于回复链)
*/
@TableField("parent_message_id")
private String parentMessageId;
/**
* 元数据
*/
@TableField(value = "metadata", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> metadata;
/**
* Coze消息角色 (user/assistant/system)
*/
@TableField("coze_role")
private String cozeRole;
/**
* Coze消息内容类型 (text/image/file等)
*/
@TableField("coze_content_type")
private String cozeContentType;
/**
* 用户ID (注册用户或访客用户)
*/
@TableField("user_id")
private String userId;
/**
* 用户类型 (registered/guest)
*/
@TableField("user_type")
private String userType;
}
@@ -1,91 +0,0 @@
package com.emotionmuseum.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotionmuseum.ai.entity.Conversation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
/**
* 对话Mapper
*
* @author huazhongmin
* @since 2025-07-12
*/
@Mapper
public interface ConversationMapper extends BaseMapper<Conversation> {
/**
* 根据用户ID查询对话列表
*
* @param userId 用户ID
* @param limit 限制数量
* @param offset 偏移量
* @return 对话列表
*/
List<Conversation> selectByUserId(@Param("userId") String userId,
@Param("limit") Integer limit,
@Param("offset") Integer offset);
/**
* 更新对话摘要
*
* @param conversationId 对话ID
* @param summary 摘要
* @param aiInsights AI洞察
* @return 更新行数
*/
int updateSummary(@Param("conversationId") String conversationId,
@Param("summary") String summary,
@Param("aiInsights") String aiInsights);
/**
* 更新对话情绪分析
*
* @param conversationId 对话ID
* @param primaryEmotion 主要情绪
* @param emotionIntensity 情绪强度
* @param emotionTrend 情绪趋势
* @param confidence 置信度
* @return 更新行数
*/
int updateEmotionAnalysis(@Param("conversationId") String conversationId,
@Param("primaryEmotion") String primaryEmotion,
@Param("emotionIntensity") Double emotionIntensity,
@Param("emotionTrend") String emotionTrend,
@Param("confidence") Double confidence);
/**
* 增加消息数量
*
* @param conversationId 对话ID
* @return 更新行数
*/
int incrementMessageCount(@Param("conversationId") String conversationId);
/**
* 根据用户ID查询活跃会话列表
*
* @param userId 用户ID
* @return 会话列表
*/
@Select("SELECT * FROM conversation WHERE user_id = #{userId} AND status = 'active' AND is_deleted = 0 ORDER BY update_time DESC")
List<Conversation> selectActiveConversationsByUserId(@Param("userId") String userId);
/**
* 更新会话最后活跃时间和消息数量
*
* @param conversationId 会话ID
* @param lastActiveTime 最后活跃时间
* @param messageCount 消息数量
* @return 更新行数
*/
@Update("UPDATE conversation SET last_active_time = #{lastActiveTime}, message_count = #{messageCount}, update_time = NOW() WHERE id = #{conversationId}")
int updateLastActiveTime(@Param("conversationId") String conversationId,
@Param("lastActiveTime") LocalDateTime lastActiveTime,
@Param("messageCount") Integer messageCount);
}
@@ -1,40 +0,0 @@
package com.emotionmuseum.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotionmuseum.ai.entity.CozeApiCall;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/**
* Coze API调用记录 Mapper 接口
*
* @author huazhongmin
* @since 2025-07-12
*/
@Mapper
public interface CozeApiCallMapper extends BaseMapper<CozeApiCall> {
/**
* 更新API调用状态
*/
@Update("UPDATE coze_api_call SET status = #{status}, end_time = #{endTime}, update_time = #{updateTime}, response_body = #{responseBody} WHERE id = #{id}")
int updateStatusById(@Param("id") String id,
@Param("status") String status,
@Param("endTime") LocalDateTime endTime,
@Param("updateTime") LocalDateTime updateTime,
@Param("responseBody") String responseBody);
/**
* 更新API调用状态(带错误信息)
*/
@Update("UPDATE coze_api_call SET status = #{status}, end_time = #{endTime}, update_time = #{updateTime}, error_message = #{errorMessage} WHERE id = #{id}")
int updateStatusWithErrorById(@Param("id") String id,
@Param("status") String status,
@Param("endTime") LocalDateTime endTime,
@Param("updateTime") LocalDateTime updateTime,
@Param("errorMessage") String errorMessage);
}
@@ -1,64 +0,0 @@
package com.emotionmuseum.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotionmuseum.ai.entity.GuestUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 访客用户Mapper
*
* @author huazhongmin
* @since 2025-07-13
*/
@Mapper
public interface GuestUserMapper extends BaseMapper<GuestUser> {
/**
* 根据IP地址查找访客用户
*
* @param ipAddress IP地址
* @return 访客用户
*/
@Select("SELECT * FROM guest_user WHERE ip_address = #{ipAddress} AND is_deleted = 0 ORDER BY create_time DESC LIMIT 1")
GuestUser findByIpAddress(@Param("ipAddress") String ipAddress);
/**
* 根据访客用户ID查找
*
* @param guestUserId 访客用户ID
* @return 访客用户
*/
@Select("SELECT * FROM guest_user WHERE guest_user_id = #{guestUserId} AND is_deleted = 0")
GuestUser findByGuestUserId(@Param("guestUserId") String guestUserId);
/**
* 更新最后活跃时间
*
* @param guestUserId 访客用户ID
* @return 更新行数
*/
@Update("UPDATE guest_user SET last_active_time = NOW(), update_time = NOW() WHERE guest_user_id = #{guestUserId}")
int updateLastActiveTime(@Param("guestUserId") String guestUserId);
/**
* 增加会话数量
*
* @param guestUserId 访客用户ID
* @return 更新行数
*/
@Update("UPDATE guest_user SET conversation_count = conversation_count + 1, update_time = NOW() WHERE guest_user_id = #{guestUserId}")
int incrementConversationCount(@Param("guestUserId") String guestUserId);
/**
* 增加消息数量
*
* @param guestUserId 访客用户ID
* @param count 增加数量
* @return 更新行数
*/
@Update("UPDATE guest_user SET message_count = message_count + #{count}, update_time = NOW() WHERE guest_user_id = #{guestUserId}")
int incrementMessageCount(@Param("guestUserId") String guestUserId, @Param("count") int count);
}
@@ -1,117 +0,0 @@
package com.emotionmuseum.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotionmuseum.ai.entity.Message;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 消息Mapper
*
* @author huazhongmin
* @since 2025-07-12
*/
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
/**
* 根据对话ID查询消息列表
*
* @param conversationId 对话ID
* @param limit 限制数量
* @param offset 偏移量
* @return 消息列表
*/
List<Message> selectByConversationId(@Param("conversationId") String conversationId,
@Param("limit") Integer limit,
@Param("offset") Integer offset);
/**
* 标记消息为已读
*
* @param messageId 消息ID
* @return 更新行数
*/
int markAsRead(@Param("messageId") String messageId);
/**
* 批量标记消息为已读
*
* @param conversationId 对话ID
* @return 更新行数
*/
int markAllAsRead(@Param("conversationId") String conversationId);
/**
* 获取对话中的最新消息
*
* @param conversationId 对话ID
* @param limit 限制数量
* @return 消息列表
*/
List<Message> selectLatestMessages(@Param("conversationId") String conversationId,
@Param("limit") Integer limit);
/**
* 根据会话ID查询消息列表(带分页)
*
* @param conversationId 会话ID
* @param limit 限制数量
* @param offset 偏移量
* @return 消息列表
*/
@Select("SELECT * FROM message WHERE conversation_id = #{conversationId} AND is_deleted = 0 ORDER BY timestamp ASC LIMIT #{limit} OFFSET #{offset}")
List<Message> selectMessagesByConversationId(@Param("conversationId") String conversationId,
@Param("limit") Integer limit,
@Param("offset") Integer offset);
/**
* 根据会话ID查询最新消息
*
* @param conversationId 会话ID
* @param limit 限制数量
* @return 消息列表
*/
@Select("SELECT * FROM message WHERE conversation_id = #{conversationId} AND is_deleted = 0 ORDER BY timestamp DESC LIMIT #{limit}")
List<Message> selectLatestMessagesByConversationId(@Param("conversationId") String conversationId, @Param("limit") Integer limit);
/**
* 统计会话消息数量
*
* @param conversationId 会话ID
* @return 消息数量
*/
@Select("SELECT COUNT(*) FROM message WHERE conversation_id = #{conversationId} AND is_deleted = 0")
Integer countMessagesByConversationId(@Param("conversationId") String conversationId);
/**
* 标记消息为已读
*
* @param messageId 消息ID
* @return 更新行数
*/
@Update("UPDATE message SET is_read = 1, update_time = NOW() WHERE id = #{messageId}")
int markMessageAsRead(@Param("messageId") String messageId);
/**
* 批量标记会话消息为已读
*
* @param conversationId 会话ID
* @return 更新行数
*/
@Update("UPDATE message SET is_read = 1, update_time = NOW() WHERE conversation_id = #{conversationId} AND is_read = 0")
int markConversationMessagesAsRead(@Param("conversationId") String conversationId);
/**
* 查询未读消息数量
*
* @param conversationId 会话ID
* @return 未读消息数量
*/
@Select("SELECT COUNT(*) FROM message WHERE conversation_id = #{conversationId} AND is_read = 0 AND is_deleted = 0")
Integer countUnreadMessages(@Param("conversationId") String conversationId);
}
@@ -1,64 +0,0 @@
package com.emotionmuseum.ai.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* AI聊天请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "AI聊天请求")
public class AiChatRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID", example = "user_123")
@NotBlank(message = "用户ID不能为空")
private String userId;
@Schema(description = "消息内容", example = "我今天感觉有点焦虑,不知道该怎么办")
@NotBlank(message = "消息内容不能为空")
@Size(max = 2000, message = "消息内容不能超过2000字符")
private String message;
@Schema(description = "对话ID(可选)", example = "conv_123456")
private String conversationId;
@Schema(description = "消息类型", example = "text")
private String type = "text";
@Schema(description = "聊天历史(可选)")
private List<ChatMessage> history;
@Schema(description = "是否需要情绪分析", example = "true")
private Boolean needEmotionAnalysis = true;
@Schema(description = "上下文信息")
private String context;
/**
* 聊天消息
*/
@Data
@Schema(description = "聊天消息")
public static class ChatMessage {
@Schema(description = "角色", example = "user")
private String role; // user, assistant
@Schema(description = "消息内容")
private String content;
@Schema(description = "时间戳")
private Long timestamp;
}
}
@@ -1,38 +0,0 @@
package com.emotionmuseum.ai.request;
import com.emotionmuseum.common.request.BasePageRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 会话列表请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "会话列表请求")
public class ConversationListRequest extends BasePageRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID", example = "user_123")
private String userId;
@Schema(description = "会话类型", example = "emotion_chat")
private String type;
@Schema(description = "会话状态", example = "active")
private String status;
@Schema(description = "用户类型", example = "guest")
private String userType;
@Schema(description = "开始时间", example = "2025-01-01 00:00:00")
private String startTime;
@Schema(description = "结束时间", example = "2025-12-31 23:59:59")
private String endTime;
}
@@ -1,38 +0,0 @@
package com.emotionmuseum.ai.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
/**
* 创建会话请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "创建会话请求")
public class CreateConversationRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID", example = "user_123")
@NotBlank(message = "用户ID不能为空")
private String userId;
@Schema(description = "会话标题", example = "今日心情分享")
private String title;
@Schema(description = "会话类型", example = "emotion_chat")
private String type = "emotion_chat";
@Schema(description = "初始消息", example = "你好,我想聊聊今天的心情")
private String initialMessage;
@Schema(description = "上下文信息")
private String context;
}
@@ -1,41 +0,0 @@
package com.emotionmuseum.ai.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 情绪分析请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "情绪分析请求")
public class EmotionAnalysisRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID", example = "user_123")
@NotBlank(message = "用户ID不能为空")
private String userId;
@Schema(description = "待分析文本", example = "我今天感觉很沮丧,工作压力很大")
@NotBlank(message = "待分析文本不能为空")
@Size(max = 1000, message = "待分析文本不能超过1000字符")
private String text;
@Schema(description = "分析类型", example = "detailed")
private String analysisType = "detailed"; // simple, detailed
@Schema(description = "语言", example = "zh")
private String language = "zh";
@Schema(description = "上下文信息")
private String context;
}
@@ -1,43 +0,0 @@
package com.emotionmuseum.ai.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 访客聊天请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "访客聊天请求")
public class GuestChatRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "消息内容", example = "你好,我想聊聊今天的心情")
@NotBlank(message = "消息内容不能为空")
@Size(max = 2000, message = "消息内容不能超过2000字符")
private String message;
@Schema(description = "会话ID(可选,如果不提供则创建新会话)", example = "conv_123456")
private String conversationId;
@Schema(description = "会话标题(创建新会话时使用)", example = "今日心情分享")
private String title;
@Schema(description = "消息类型", example = "text")
private String messageType = "text";
@Schema(description = "是否流式响应", example = "false")
private Boolean stream = false;
@Schema(description = "附加上下文信息")
private String context;
}
@@ -1,100 +0,0 @@
package com.emotionmuseum.ai.response;
import com.emotionmuseum.common.response.BaseResponse;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* AI聊天响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "AI聊天响应")
public class AiChatResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "消息ID")
private String messageId;
@Schema(description = "对话ID")
private String conversationId;
@Schema(description = "AI回复内容")
private String content;
@Schema(description = "消息类型", example = "text")
private String type = "text";
@Schema(description = "发送者", example = "assistant")
private String sender = "assistant";
@Schema(description = "响应时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime responseTime;
@Schema(description = "情绪分析结果")
private EmotionAnalysisResponse emotionAnalysis;
@Schema(description = "使用情况")
private Usage usage;
@Schema(description = "元数据")
private Map<String, Object> metadata;
@Schema(description = "是否为多条消息")
private Boolean multipleMessages = false;
@Schema(description = "消息数量")
private Integer messageCount = 1;
@Schema(description = "所有消息ID列表(当拆分为多条消息时)")
private List<String> messageIds;
/**
* 使用情况
*/
@Data
@Schema(description = "使用情况")
public static class Usage {
@Schema(description = "输入Token数")
private Integer promptTokens;
@Schema(description = "输出Token数")
private Integer completionTokens;
@Schema(description = "总Token数")
private Integer totalTokens;
}
/**
* 情绪分析响应
*/
@Data
@Schema(description = "情绪分析响应")
public static class EmotionAnalysisResponse {
@Schema(description = "情绪类型")
private String emotionType;
@Schema(description = "情绪强度")
private Double intensity;
@Schema(description = "情绪描述")
private String description;
@Schema(description = "建议")
private String suggestion;
@Schema(description = "置信度")
private Double confidence;
}
}
@@ -1,61 +0,0 @@
package com.emotionmuseum.ai.response;
import com.emotionmuseum.common.response.BaseResponse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 会话列表响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "会话列表响应")
public class ConversationListResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "会话ID")
private String conversationId;
@Schema(description = "会话标题")
private String title;
@Schema(description = "会话类型")
private String type;
@Schema(description = "会话状态")
private String status;
@Schema(description = "用户ID")
private String userId;
@Schema(description = "用户类型")
private String userType;
@Schema(description = "消息数量")
private Integer messageCount;
@Schema(description = "最后活跃时间")
private LocalDateTime lastActiveTime;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "主要情绪")
private String primaryEmotion;
@Schema(description = "情绪强度")
private Double emotionIntensity;
@Schema(description = "Coze会话ID")
private String cozeConversationId;
}
@@ -1,53 +0,0 @@
package com.emotionmuseum.ai.response;
import com.emotionmuseum.common.response.BaseResponse;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 创建会话响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "创建会话响应")
public class CreateConversationResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "会话ID")
private String conversationId;
@Schema(description = "用户ID")
private String userId;
@Schema(description = "会话标题")
private String title;
@Schema(description = "会话类型")
private String type;
@Schema(description = "会话状态", example = "active")
private String status = "active";
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
@Schema(description = "Coze会话ID")
private String cozeConversationId;
@Schema(description = "元数据")
private Map<String, Object> metadata;
}
@@ -1,69 +0,0 @@
package com.emotionmuseum.ai.response;
import com.emotionmuseum.common.response.BaseResponse;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 情绪分析响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "情绪分析响应")
public class EmotionAnalysisResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "主要情绪", example = "焦虑")
private String primaryEmotion;
@Schema(description = "情绪强度", example = "0.75")
private Double intensity;
@Schema(description = "情绪极性", example = "negative")
private String polarity; // positive, negative, neutral
@Schema(description = "置信度", example = "0.85")
private Double confidence;
@Schema(description = "情绪分布")
private List<EmotionScore> emotions;
@Schema(description = "关键词")
private List<String> keywords;
@Schema(description = "建议")
private String suggestion;
@Schema(description = "分析时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime analysisTime;
@Schema(description = "额外信息")
private Map<String, Object> metadata;
/**
* 情绪得分
*/
@Data
@Schema(description = "情绪得分")
public static class EmotionScore {
@Schema(description = "情绪名称")
private String emotion;
@Schema(description = "得分")
private Double score;
@Schema(description = "描述")
private String description;
}
}
@@ -1,111 +0,0 @@
package com.emotionmuseum.ai.response;
import com.emotionmuseum.common.response.BaseResponse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
/**
* 访客聊天响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "访客聊天响应")
public class GuestChatResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "访客用户ID")
private String guestUserId;
@Schema(description = "访客昵称")
private String guestNickname;
@Schema(description = "会话ID")
private String conversationId;
@Schema(description = "会话标题")
private String conversationTitle;
@Schema(description = "用户消息ID")
private String userMessageId;
@Schema(description = "AI回复消息ID")
private String aiMessageId;
@Schema(description = "用户消息内容")
private String userMessage;
@Schema(description = "AI回复内容")
private String aiReply;
@Schema(description = "消息时间戳")
private LocalDateTime messageTimestamp;
@Schema(description = "会话状态")
private String conversationStatus;
@Schema(description = "是否为新会话")
private Boolean isNewConversation;
@Schema(description = "Coze聊天ID")
private String cozeChatId;
@Schema(description = "情绪分析结果")
private EmotionAnalysisResult emotionAnalysis;
@Schema(description = "Token使用情况")
private TokenUsage tokenUsage;
@Schema(description = "错误信息(如果有)")
private String errorMessage;
/**
* 情绪分析结果内部类
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "情绪分析结果")
public static class EmotionAnalysisResult {
@Schema(description = "主要情绪")
private String primaryEmotion;
@Schema(description = "情绪得分")
private Double emotionScore;
@Schema(description = "置信度")
private Double confidence;
@Schema(description = "情绪趋势")
private String emotionTrend;
}
/**
* Token使用情况内部类
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Token使用情况")
public static class TokenUsage {
@Schema(description = "输入Token数")
private Integer promptTokens;
@Schema(description = "输出Token数")
private Integer completionTokens;
@Schema(description = "总Token数")
private Integer totalTokens;
}
}
@@ -1,64 +0,0 @@
package com.emotionmuseum.ai.service;
import com.emotionmuseum.ai.dto.*;
import com.emotionmuseum.ai.entity.Message;
import java.util.List;
/**
* AI聊天服务接口
*
* @author huazhongmin
* @since 2025-07-12
*/
public interface AiChatService {
/**
* 创建会话
*
* @param request 创建会话请求
* @return 创建会话响应
*/
CreateConversationResponse createConversation(CreateConversationRequest request);
/**
* 发送聊天消息
*
* @param request 聊天请求
* @return 聊天响应
*/
ChatResponse chat(ChatRequest request);
/**
* 情绪分析
*
* @param request 情绪分析请求
* @return 情绪分析响应
*/
EmotionAnalysisResponse analyzeEmotion(EmotionAnalysisRequest request);
/**
* 流式聊天
*
* @param request 聊天请求
* @return 流式响应
*/
String streamChat(ChatRequest request);
/**
* 健康检查
*
* @return 是否健康
*/
boolean healthCheck();
/**
* 保存AI回复消息(支持拆分多条消息)
*
* @param conversationId 会话ID
* @param aiContent AI回复内容
* @param cozeChatId Coze聊天ID
* @return 保存的消息列表
*/
List<Message> saveAiReplyMessages(String conversationId, String aiContent, String cozeChatId);
}
@@ -1,207 +0,0 @@
package com.emotionmuseum.ai.service;
import com.emotionmuseum.ai.entity.Conversation;
import com.emotionmuseum.ai.entity.Message;
import com.emotionmuseum.ai.entity.CozeApiCall;
import com.emotionmuseum.common.dto.PageQuery;
import java.util.List;
/**
* 会话数据库服务接口
*
* @author huazhongmin
* @since 2025-07-12
*/
public interface ConversationDbService {
/**
* 保存会话
*
* @param conversation 会话信息
* @return 保存的会话
*/
Conversation saveConversation(Conversation conversation);
/**
* 根据ID查询会话
*
* @param conversationId 会话ID
* @return 会话信息
*/
Conversation getConversationById(String conversationId);
/**
* 根据用户ID查询会话列表
*
* @param userId 用户ID
* @param pageQuery 分页查询
* @return 会话列表
*/
List<Conversation> getConversationsByUserId(String userId, PageQuery pageQuery);
/**
* 根据用户ID查询活跃会话列表
*
* @param userId 用户ID
* @return 活跃会话列表
*/
List<Conversation> getActiveConversationsByUserId(String userId);
/**
* 更新会话状态
*
* @param conversationId 会话ID
* @param status 状态
* @return 是否成功
*/
boolean updateConversationStatus(String conversationId, String status);
/**
* 更新会话活跃时间
*
* @param conversationId 会话ID
* @return 是否成功
*/
boolean updateConversationActiveTime(String conversationId);
/**
* 保存消息
*
* @param message 消息信息
* @return 保存的消息
*/
Message saveMessage(Message message);
/**
* 根据会话ID查询消息列表
*
* @param conversationId 会话ID
* @param pageQuery 分页查询
* @return 消息列表
*/
List<Message> getMessagesByConversationId(String conversationId, PageQuery pageQuery);
/**
* 根据会话ID查询最新消息
*
* @param conversationId 会话ID
* @param limit 限制数量
* @return 消息列表
*/
List<Message> getLatestMessages(String conversationId, Integer limit);
/**
* 标记消息为已读
*
* @param messageId 消息ID
* @return 是否成功
*/
boolean markMessageAsRead(String messageId);
/**
* 标记会话所有消息为已读
*
* @param conversationId 会话ID
* @return 是否成功
*/
boolean markConversationMessagesAsRead(String conversationId);
/**
* 统计会话消息数量
*
* @param conversationId 会话ID
* @return 消息数量
*/
Integer getMessageCount(String conversationId);
/**
* 统计未读消息数量
*
* @param conversationId 会话ID
* @return 未读消息数量
*/
Integer getUnreadMessageCount(String conversationId);
/**
* 删除会话
*
* @param conversationId 会话ID
* @return 是否成功
*/
boolean deleteConversation(String conversationId);
/**
* 根据Coze对话ID查询会话
*
* @param cozeConversationId Coze对话ID
* @return 会话信息
*/
Conversation getConversationByCozeId(String cozeConversationId);
/**
* 更新会话的Coze相关信息
*
* @param conversationId 会话ID
* @param cozeConversationId Coze对话ID
* @param botId Bot ID
* @param workflowId Workflow ID
* @return 是否成功
*/
boolean updateConversationCozeInfo(String conversationId, String cozeConversationId, String botId,
String workflowId);
/**
* 更新消息的Coze相关信息
*
* @param messageId 消息ID
* @param cozeChatId Coze聊天ID
* @param cozeMessageId Coze消息ID
* @param status 状态
* @return 是否成功
*/
boolean updateMessageCozeInfo(String messageId, String cozeChatId, String cozeMessageId, String status);
/**
* 保存Coze API调用记录
*
* @param cozeApiCall API调用记录
* @return 保存的记录
*/
CozeApiCall saveCozeApiCall(CozeApiCall cozeApiCall);
/**
* 更新Coze API调用记录状态
*
* @param callId 调用记录ID
* @param status 状态
* @param responseBody 响应体
* @param errorMessage 错误信息
* @return 是否成功
*/
boolean updateCozeApiCallStatus(String callId, String status, Object responseBody, String errorMessage);
/**
* 根据ID获取Coze API调用记录
*
* @param callId 调用记录ID
* @return API调用记录
*/
CozeApiCall getCozeApiCallById(String callId);
/**
* 更新Coze API调用记录
*
* @param cozeApiCall API调用记录
* @return 是否成功
*/
boolean updateCozeApiCall(CozeApiCall cozeApiCall);
/**
* 根据ID列表获取消息
*
* @param messageIds 消息ID列表
* @return 消息列表
*/
List<Message> getMessagesByIds(List<String> messageIds);
}
@@ -1,71 +0,0 @@
package com.emotionmuseum.ai.service;
import com.emotionmuseum.ai.dto.*;
import com.emotionmuseum.common.result.Result;
import java.util.List;
/**
* 访客聊天服务接口
*
* @author huazhongmin
* @since 2025-07-13
*/
public interface GuestChatService {
/**
* 访客聊天
*
* @param request 聊天请求
* @return 聊天响应
*/
Result<GuestChatResponse> guestChat(GuestChatRequest request);
/**
* 获取访客会话列表
*
* @param clientIp 客户端IP
* @param pageNum 页码
* @param pageSize 页大小
* @return 会话列表
*/
Result<List<ConversationListResponse>> getGuestConversations(String clientIp, Integer pageNum, Integer pageSize);
/**
* 获取访客会话消息列表
*
* @param conversationId 会话ID
* @param clientIp 客户端IP
* @param pageNum 页码
* @param pageSize 页大小
* @return 消息列表
*/
Result<List<MessageListResponse>> getGuestConversationMessages(String conversationId, String clientIp, Integer pageNum, Integer pageSize);
/**
* 结束访客会话
*
* @param conversationId 会话ID
* @param clientIp 客户端IP
* @return 操作结果
*/
Result<Void> endGuestConversation(String conversationId, String clientIp);
/**
* 获取或创建访客用户
*
* @param clientIp 客户端IP
* @param userAgent 用户代理
* @return 访客用户信息
*/
Result<GuestUserInfo> getOrCreateGuestUser(String clientIp, String userAgent);
/**
* 访客情绪分析
*
* @param request 情绪分析请求
* @param clientIp 客户端IP
* @return 情绪分析结果
*/
Result<EmotionAnalysisResponse> analyzeGuestEmotion(EmotionAnalysisRequest request, String clientIp);
}
@@ -1,52 +0,0 @@
package com.emotionmuseum.ai.service;
import com.emotionmuseum.ai.dto.GuestUserInfo;
/**
* 访客用户服务接口
*
* @author huazhongmin
* @since 2025-07-13
*/
public interface GuestUserService {
/**
* 根据IP地址获取或创建访客用户
*
* @param ipAddress 客户端IP地址
* @param userAgent 用户代理信息
* @return 访客用户信息
*/
GuestUserInfo getOrCreateGuestUser(String ipAddress, String userAgent);
/**
* 根据访客ID获取访客用户信息
*
* @param guestUserId 访客用户ID
* @return 访客用户信息
*/
GuestUserInfo getGuestUserById(String guestUserId);
/**
* 更新访客用户最后活跃时间
*
* @param guestUserId 访客用户ID
*/
void updateLastActiveTime(String guestUserId);
/**
* 检查是否为访客用户ID
*
* @param userId 用户ID
* @return 是否为访客用户
*/
boolean isGuestUser(String userId);
/**
* 生成访客用户ID
*
* @param ipAddress IP地址
* @return 访客用户ID
*/
String generateGuestUserId(String ipAddress);
}
@@ -1,800 +0,0 @@
package com.emotionmuseum.ai.service.impl;
import com.emotionmuseum.ai.config.FeatureConfig;
import com.emotionmuseum.ai.dto.*;
import com.emotionmuseum.ai.entity.Conversation;
import com.emotionmuseum.ai.entity.Message;
import com.emotionmuseum.ai.entity.CozeApiCall;
import com.emotionmuseum.ai.service.AiChatService;
import com.emotionmuseum.ai.service.ConversationDbService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.LocalDateTime;
import java.util.*;
/**
* AI聊天服务实现类
*
* @author huazhongmin
* @since 2025-07-12
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiChatServiceImpl implements AiChatService {
private final WebClient cozeWebClient;
private final ConversationDbService conversationDbService;
private final FeatureConfig featureConfig;
@Value("${coze.bot-id}")
private String botId;
@Value("${coze.workflow-id:}")
private String workflowId;
@Value("${coze.user-id:emotion-museum-user}")
private String defaultUserId;
@Override
public CreateConversationResponse createConversation(CreateConversationRequest request) {
log.info("创建会话请求: userId={}, title={}", request.getUserId(), request.getTitle());
try {
// 处理用户类型
String userId = request.getUserId();
String userType = userId != null && userId.startsWith("guest_") ? "guest" : "registered";
// 调用Coze API创建会话
Map<String, Object> cozeResponse = cozeWebClient.post()
.uri("/v1/conversation/create")
.retrieve()
.bodyToMono(Map.class)
.block();
// 创建会话实体
Conversation conversation = new Conversation();
conversation.setUserId(userId);
conversation.setUserType(userType);
conversation.setTitle(request.getTitle() != null ? request.getTitle() : "新会话");
conversation.setType(request.getType());
conversation.setStatus("active");
conversation.setInitialMessage(request.getInitialMessage());
conversation.setContext(request.getContext());
conversation.setStartTime(LocalDateTime.now());
conversation.setLastActiveTime(LocalDateTime.now());
conversation.setMessageCount(0);
conversation.setBotId(botId);
conversation.setWorkflowId(workflowId);
// 设置客户端信息(访客模式下会有这些信息)
// 这些字段在CreateConversationRequest中可能不存在,暂时跳过
// 解析Coze响应获取会话ID
if (cozeResponse != null && cozeResponse.get("data") != null) {
Map<String, Object> data = (Map<String, Object>) cozeResponse.get("data");
if (data.get("id") != null) {
conversation.setCozeConversationId(data.get("id").toString());
}
}
// 保存到数据库
Conversation savedConversation = conversationDbService.saveConversation(conversation);
// 构建响应
CreateConversationResponse response = new CreateConversationResponse();
response.setConversationId(savedConversation.getId());
response.setUserId(savedConversation.getUserId());
response.setTitle(savedConversation.getTitle());
response.setType(savedConversation.getType());
response.setStatus(savedConversation.getStatus());
response.setCozeConversationId(savedConversation.getCozeConversationId());
response.setCreateTime(savedConversation.getCreateTime());
response.setUpdateTime(savedConversation.getUpdateTime());
log.info("会话创建成功: conversationId={}, cozeConversationId={}",
response.getConversationId(), response.getCozeConversationId());
return response;
} catch (Exception e) {
log.error("创建会话失败: userId={}, error={}", request.getUserId(), e.getMessage(), e);
throw new RuntimeException("创建会话失败: " + e.getMessage(), e);
}
}
@Override
public com.emotionmuseum.ai.dto.ChatResponse chat(ChatRequest request) {
log.info("处理聊天请求: userId={}, message={}", request.getUserId(), request.getMessage());
try {
// 构建Coze API请求
Map<String, Object> cozeRequest = buildCozeRequest(request);
// 保存用户消息到数据库
Message userMessage = new Message();
userMessage.setConversationId(request.getConversationId());
userMessage.setContent(request.getMessage());
userMessage.setType(request.getType() != null ? request.getType() : "text");
userMessage.setSender("user");
userMessage.setTimestamp(LocalDateTime.now());
userMessage.setStatus("sent");
userMessage.setIsRead(1);
Message savedUserMessage = conversationDbService.saveMessage(userMessage);
// 创建API调用记录
CozeApiCall apiCall = new CozeApiCall();
apiCall.setConversationId(request.getConversationId());
apiCall.setMessageId(savedUserMessage.getId());
apiCall.setBotId(botId);
apiCall.setWorkflowId(workflowId);
apiCall.setUserId(request.getUserId());
apiCall.setRequestType("chat");
apiCall.setRequestUrl("/v3/chat");
apiCall.setRequestBody((Map<String, Object>) cozeRequest);
// 保存用户消息内容
apiCall.setUserMessage(request.getMessage());
apiCall.setUserMessageType("text");
// 设置客户端信息
apiCall.setClientIp(getClientIpFromRequest());
apiCall.setUserAgent(getUserAgentFromRequest());
apiCall.setSessionId(generateSessionId(request));
apiCall.setTraceId(generateTraceId());
apiCall.setStatus("pending");
apiCall.setStartTime(LocalDateTime.now());
CozeApiCall savedApiCall = conversationDbService.saveCozeApiCall(apiCall);
// 调用Coze API
log.info("发送Coze请求: {}", cozeRequest);
Map<String, Object> cozeResponse = cozeWebClient.post()
.uri("/v3/chat")
.bodyValue(cozeRequest)
.retrieve()
.bodyToMono(Map.class)
.block();
log.info("收到Coze初始响应: {}", cozeResponse);
// 解析Coze响应并获取AI回复
String aiContent = "抱歉,我现在无法理解您的消息。";
String cozeChatId = null;
String cozeConversationId = null;
if (cozeResponse != null && cozeResponse.get("data") != null) {
Map<String, Object> data = (Map<String, Object>) cozeResponse.get("data");
cozeChatId = (String) data.get("id");
cozeConversationId = (String) data.get("conversation_id");
// 更新API调用记录
conversationDbService.updateCozeApiCallStatus(savedApiCall.getId(), "success", cozeResponse, null);
if (cozeChatId != null && cozeConversationId != null) {
// 更新会话的Coze信息
conversationDbService.updateConversationCozeInfo(
request.getConversationId(), cozeConversationId, botId, workflowId);
// 轮询聊天状态直到完成并获取回复内容
ChatCompletionResult result = waitForChatCompletionWithResult(cozeChatId, cozeConversationId);
aiContent = result.getContent();
// 更新API调用记录
updateCozeApiCallWithResult(savedApiCall.getId(), result, aiContent);
}
} else {
// 更新API调用记录为失败
conversationDbService.updateCozeApiCallStatus(savedApiCall.getId(), "failed", null,
"No valid response from Coze API");
}
// 保存AI回复消息到数据库(支持拆分多条消息)
List<Message> savedAiMessages = saveAiReplyMessages(request.getConversationId(), aiContent, cozeChatId);
Message savedAiMessage = savedAiMessages.get(savedAiMessages.size() - 1); // 获取最后一条消息作为主要回复
// 构建响应
com.emotionmuseum.ai.dto.ChatResponse response = new com.emotionmuseum.ai.dto.ChatResponse();
response.setMessageId(savedAiMessage.getId());
response.setConversationId(request.getConversationId());
response.setContent(aiContent);
response.setTimestamp(savedAiMessage.getTimestamp());
// 添加多条消息信息
if (savedAiMessages.size() > 1) {
response.setMultipleMessages(true);
response.setMessageCount(savedAiMessages.size());
response.setMessageIds(savedAiMessages.stream()
.map(Message::getId)
.collect(java.util.stream.Collectors.toList()));
log.info("AI回复已拆分为{}条消息: conversationId={}, messageIds={}",
savedAiMessages.size(), request.getConversationId(), response.getMessageIds());
} else {
response.setMultipleMessages(false);
response.setMessageCount(1);
}
// 如果需要情绪分析且功能已启用
if (Boolean.TRUE.equals(request.getNeedEmotionAnalysis()) &&
featureConfig.getEmotionAnalysis().isEnabled()) {
try {
EmotionAnalysisRequest emotionRequest = new EmotionAnalysisRequest();
emotionRequest.setUserId(request.getUserId());
emotionRequest.setText(request.getMessage());
response.setEmotionAnalysis(analyzeEmotion(emotionRequest));
log.debug("情绪分析完成: userId={}", request.getUserId());
} catch (Exception e) {
log.warn("情绪分析失败,跳过: userId={}, error={}", request.getUserId(), e.getMessage());
// 情绪分析失败不影响聊天功能
}
} else if (Boolean.TRUE.equals(request.getNeedEmotionAnalysis())) {
log.debug("情绪分析功能已禁用,跳过分析: userId={}", request.getUserId());
}
log.info("聊天响应生成成功: messageId={}", response.getMessageId());
return response;
} catch (Exception e) {
log.error("聊天处理失败: userId={}, error={}", request.getUserId(), e.getMessage(), e);
throw new RuntimeException("聊天处理失败: " + e.getMessage(), e);
}
}
@Override
public EmotionAnalysisResponse analyzeEmotion(EmotionAnalysisRequest request) {
log.info("处理情绪分析请求: userId={}, text={}", request.getUserId(), request.getText());
// 检查情绪分析功能是否启用
if (!featureConfig.getEmotionAnalysis().isEnabled()) {
log.warn("情绪分析功能已禁用: userId={}", request.getUserId());
throw new RuntimeException("情绪分析功能暂时不可用");
}
try {
// 构建情绪分析请求
Map<String, Object> cozeRequest = new HashMap<>();
cozeRequest.put("bot_id", botId);
cozeRequest.put("user_id", request.getUserId() != null ? request.getUserId() : defaultUserId);
cozeRequest.put("stream", false);
String prompt = buildEmotionAnalysisPrompt(request.getText());
List<Map<String, Object>> messages = new ArrayList<>();
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
message.put("content_type", "text");
messages.add(message);
cozeRequest.put("additional_messages", messages);
// 调用Coze API
Map<String, Object> cozeResponse = cozeWebClient.post()
.uri("/v3/chat")
.bodyValue(cozeRequest)
.retrieve()
.bodyToMono(Map.class)
.block();
// 解析AI返回的情绪分析结果
String result = "";
if (cozeResponse != null && cozeResponse.get("data") != null) {
Map<String, Object> data = (Map<String, Object>) cozeResponse.get("data");
result = extractContentFromCozeResponse(data);
}
return parseEmotionAnalysisResult(result);
} catch (Exception e) {
log.error("情绪分析失败: userId={}, error={}", request.getUserId(), e.getMessage(), e);
throw new RuntimeException("情绪分析失败: " + e.getMessage(), e);
}
}
@Override
public String streamChat(ChatRequest request) {
log.info("处理流式聊天请求: userId={}", request.getUserId());
try {
// 构建流式请求
Map<String, Object> cozeRequest = buildCozeRequest(request);
cozeRequest.put("stream", true); // 启用流式响应
log.debug("发送流式Coze请求: {}", cozeRequest);
// 调用Coze流式API
String streamResponse = cozeWebClient.post()
.uri("/v3/chat")
.bodyValue(cozeRequest)
.retrieve()
.bodyToMono(String.class)
.block();
log.debug("收到流式Coze响应: {}", streamResponse);
// 解析流式响应并提取最终内容
String finalContent = parseStreamResponse(streamResponse);
return finalContent != null ? finalContent : "抱歉,流式聊天暂时无法处理您的请求。";
} catch (Exception e) {
log.error("流式聊天失败: userId={}, error={}", request.getUserId(), e.getMessage(), e);
// 降级到普通聊天
try {
com.emotionmuseum.ai.dto.ChatResponse response = chat(request);
return response.getContent();
} catch (Exception fallbackError) {
log.error("降级聊天也失败: {}", fallbackError.getMessage());
return "抱歉,聊天服务暂时不可用,请稍后再试。";
}
}
}
@Override
public boolean healthCheck() {
try {
// 调用Coze bot信息接口检查健康状态
Map<String, Object> response = cozeWebClient.get()
.uri("/v1/bot/get_online_info?bot_id=" + botId)
.retrieve()
.bodyToMono(Map.class)
.block();
return response != null && response.get("code") != null;
} catch (Exception e) {
log.error("健康检查失败: {}", e.getMessage());
return false;
}
}
/**
* 构建Coze API请求
*/
private Map<String, Object> buildCozeRequest(ChatRequest request) {
Map<String, Object> cozeRequest = new HashMap<>();
cozeRequest.put("bot_id", botId);
// 如果有workflow_id,则添加
if (workflowId != null && !workflowId.trim().isEmpty()) {
cozeRequest.put("workflow_id", workflowId);
}
cozeRequest.put("user_id", request.getUserId() != null ? request.getUserId() : defaultUserId);
cozeRequest.put("stream", false);
// 构建消息内容
String message = request.getMessage();
if (request.getContext() != null && !request.getContext().trim().isEmpty()) {
message = "上下文: " + request.getContext() + "\n\n用户消息: " + message;
}
// 添加聊天历史
List<Map<String, Object>> messages = new ArrayList<>();
if (request.getHistory() != null && !request.getHistory().isEmpty()) {
for (ChatRequest.ChatMessage historyMsg : request.getHistory()) {
Map<String, Object> msg = new HashMap<>();
msg.put("role", historyMsg.getRole());
msg.put("content", historyMsg.getContent());
msg.put("content_type", "text");
msg.put("type", "user".equals(historyMsg.getRole()) ? "question" : "answer");
messages.add(msg);
}
}
// 添加当前消息
Map<String, Object> currentMsg = new HashMap<>();
currentMsg.put("role", "user");
currentMsg.put("content", message);
currentMsg.put("content_type", "text");
currentMsg.put("type", "question");
messages.add(currentMsg);
cozeRequest.put("additional_messages", messages);
cozeRequest.put("parameters", new HashMap<>());
return cozeRequest;
}
/**
* 聊天完成结果类
*/
private static class ChatCompletionResult {
private final boolean success;
private final String content;
private final Map<String, Object> finalResponse;
private final String errorMessage;
public ChatCompletionResult(boolean success, String content, Map<String, Object> finalResponse,
String errorMessage) {
this.success = success;
this.content = content;
this.finalResponse = finalResponse;
this.errorMessage = errorMessage;
}
public boolean isSuccess() {
return success;
}
public String getContent() {
return content;
}
public Map<String, Object> getFinalResponse() {
return finalResponse;
}
public String getErrorMessage() {
return errorMessage;
}
}
/**
* 等待聊天完成并获取回复内容(带详细结果)
*/
private ChatCompletionResult waitForChatCompletionWithResult(String chatId, String conversationId) {
try {
// 最多等待30秒,每2秒轮询一次
int maxAttempts = 15;
int attempt = 0;
while (attempt < maxAttempts) {
// 检查聊天状态
log.info("轮询聊天状态,第{}次尝试: chatId={}, conversationId={}", attempt + 1, chatId, conversationId);
Map<String, Object> statusResponse = cozeWebClient.get()
.uri("/v3/chat/retrieve?chat_id={chatId}&conversation_id={conversationId}",
chatId, conversationId)
.retrieve()
.bodyToMono(Map.class)
.block();
log.info("轮询响应: {}", statusResponse);
if (statusResponse != null && statusResponse.get("data") != null) {
Map<String, Object> data = (Map<String, Object>) statusResponse.get("data");
String status = (String) data.get("status");
log.info("聊天状态: {}", status);
if ("completed".equals(status)) {
// 聊天完成,获取消息
log.info("聊天完成,开始获取消息: chatId={}, conversationId={}", chatId, conversationId);
String content = getChatMessages(chatId, conversationId);
return new ChatCompletionResult(true, content, statusResponse, null);
} else if ("failed".equals(status)) {
log.error("Coze聊天失败: chatId={}, conversationId={}", chatId, conversationId);
return new ChatCompletionResult(false, "抱歉,AI服务暂时不可用,请稍后再试。", statusResponse, "Chat failed");
}
} else {
log.warn("轮询响应为空或无data字段: {}", statusResponse);
}
// 等待2秒后重试
Thread.sleep(2000);
attempt++;
}
log.warn("Coze聊天超时: chatId={}, conversationId={}", chatId, conversationId);
return new ChatCompletionResult(false, "抱歉,AI响应超时,请稍后再试。", null, "Timeout");
} catch (Exception e) {
log.error("等待Coze聊天完成失败: chatId={}, conversationId={}, error={}",
chatId, conversationId, e.getMessage(), e);
return new ChatCompletionResult(false, "抱歉,AI服务出现错误,请稍后再试。", null, e.getMessage());
}
}
/**
* 等待聊天完成并获取回复内容
*/
private String waitForChatCompletion(String chatId, String conversationId) {
ChatCompletionResult result = waitForChatCompletionWithResult(chatId, conversationId);
return result.getContent();
}
/**
* 更新Coze API调用记录
*/
private void updateCozeApiCallWithResult(String apiCallId, ChatCompletionResult result, String aiReply) {
try {
CozeApiCall updateRecord = new CozeApiCall();
updateRecord.setId(apiCallId);
updateRecord.setEndTime(LocalDateTime.now());
updateRecord.setAiReply(aiReply);
updateRecord.setAiReplyType("text");
if (result.isSuccess()) {
updateRecord.setStatus("success");
updateRecord.setFinalStatus("completed");
// 提取token使用情况
Map<String, Object> finalResponse = result.getFinalResponse();
if (finalResponse != null && finalResponse.get("data") != null) {
Map<String, Object> data = (Map<String, Object>) finalResponse.get("data");
Map<String, Object> usage = (Map<String, Object>) data.get("usage");
if (usage != null) {
updateRecord.setPromptTokens((Integer) usage.get("input_count"));
updateRecord.setCompletionTokens((Integer) usage.get("output_count"));
updateRecord.setTotalTokens((Integer) usage.get("token_count"));
}
}
} else {
updateRecord.setStatus("failed");
updateRecord.setFinalStatus("failed");
updateRecord.setErrorMessage(result.getErrorMessage());
}
// 保存最终响应
updateRecord.setResponseBody(result.getFinalResponse());
// 计算耗时
CozeApiCall originalRecord = conversationDbService.getCozeApiCallById(apiCallId);
if (originalRecord != null && originalRecord.getStartTime() != null) {
long duration = java.time.Duration.between(originalRecord.getStartTime(), updateRecord.getEndTime())
.toMillis();
updateRecord.setDurationMs((int) duration);
}
conversationDbService.updateCozeApiCall(updateRecord);
log.info("更新API调用记录成功: apiCallId={}, status={}, aiReply={}",
apiCallId, updateRecord.getStatus(),
aiReply != null ? aiReply.substring(0, Math.min(50, aiReply.length())) + "..." : "null");
} catch (Exception e) {
log.error("更新API调用记录失败: apiCallId={}, error={}", apiCallId, e.getMessage(), e);
}
}
/**
* 获取客户端IP
*/
private String getClientIpFromRequest() {
// 这里可以从RequestContextHolder获取,暂时返回默认值
return "127.0.0.1";
}
/**
* 获取用户代理
*/
private String getUserAgentFromRequest() {
// 这里可以从RequestContextHolder获取,暂时返回默认值
return "EmotionMuseum-Client";
}
/**
* 生成会话ID
*/
private String generateSessionId(ChatRequest request) {
return "session_" + request.getUserId() + "_" + System.currentTimeMillis();
}
/**
* 生成追踪ID
*/
private String generateTraceId() {
return "trace_" + System.currentTimeMillis() + "_" + (int) (Math.random() * 10000);
}
/**
* 保存AI回复消息(支持拆分多条消息)
* 当AI回复中包含\n\n或\n时,将消息拆分成多条,模拟真实对话
*/
public List<Message> saveAiReplyMessages(String conversationId, String aiContent, String cozeChatId) {
List<Message> savedMessages = new ArrayList<>();
if (aiContent == null || aiContent.trim().isEmpty()) {
log.warn("AI回复内容为空,跳过保存");
return savedMessages;
}
// 优先按\n\n拆分,如果没有\n\n则按\n拆分
String[] messageParts;
String splitPattern;
if (aiContent.contains("\n\n")) {
messageParts = aiContent.split("\\n\\n");
splitPattern = "\\n\\n";
log.info("AI回复包含\\n\\n,按双换行拆分为{}条消息: conversationId={}", messageParts.length, conversationId);
} else if (aiContent.contains("\n")) {
messageParts = aiContent.split("\\n");
splitPattern = "\\n";
log.info("AI回复包含\\n,按单换行拆分为{}条消息: conversationId={}", messageParts.length, conversationId);
} else {
// 没有换行符,作为单条消息处理
messageParts = new String[] { aiContent };
splitPattern = "none";
log.info("AI回复无换行符,作为单条消息处理: conversationId={}", conversationId);
}
for (int i = 0; i < messageParts.length; i++) {
String part = messageParts[i].trim();
if (part.isEmpty()) {
continue; // 跳过空白部分
}
Message aiMessage = new Message();
aiMessage.setConversationId(conversationId);
aiMessage.setContent(part);
aiMessage.setType("text");
aiMessage.setSender("assistant");
aiMessage.setTimestamp(LocalDateTime.now().plusSeconds(i)); // 每条消息间隔1秒,模拟真实对话
aiMessage.setStatus("sent");
aiMessage.setCozeChatId(cozeChatId);
aiMessage.setIsRead(0);
// 为拆分的消息添加序号标识和拆分模式
if (messageParts.length > 1) {
String splitInfo = "none".equals(splitPattern) ? "" : " (按" + splitPattern + "拆分)";
aiMessage.setRemarks("分段消息 " + (i + 1) + "/" + messageParts.length + splitInfo);
}
Message savedMessage = conversationDbService.saveMessage(aiMessage);
savedMessages.add(savedMessage);
log.info("保存AI回复消息片段 {}/{}: messageId={}, content={}",
i + 1, messageParts.length, savedMessage.getId(),
part.length() > 50 ? part.substring(0, 50) + "..." : part);
}
return savedMessages;
}
/**
* 获取聊天消息
*/
private String getChatMessages(String chatId, String conversationId) {
try {
log.info("获取聊天消息: chatId={}, conversationId={}", chatId, conversationId);
Map<String, Object> messagesResponse = cozeWebClient.get()
.uri("/v3/chat/message/list?chat_id={chatId}&conversation_id={conversationId}",
chatId, conversationId)
.retrieve()
.bodyToMono(Map.class)
.block();
log.info("消息响应: {}", messagesResponse);
if (messagesResponse != null && messagesResponse.get("data") != null) {
List<Map<String, Object>> messages = (List<Map<String, Object>>) messagesResponse.get("data");
log.info("收到{}条消息", messages.size());
// 查找AI的回复消息(role=assistant, type=answer
for (Map<String, Object> message : messages) {
String role = (String) message.get("role");
String type = (String) message.get("type");
log.info("消息详情: role={}, type={}, content={}", role, type, message.get("content"));
if ("assistant".equals(role) && "answer".equals(type)) {
String content = (String) message.get("content");
log.info("找到AI回复: {}", content);
return content;
}
}
log.warn("未找到AI回复消息");
} else {
log.warn("消息响应为空或无data字段");
}
return "抱歉,未能获取到AI回复。";
} catch (Exception e) {
log.error("获取Coze聊天消息失败: chatId={}, conversationId={}, error={}",
chatId, conversationId, e.getMessage(), e);
return "抱歉,获取AI回复失败。";
}
}
/**
* 解析流式响应
*/
private String parseStreamResponse(String streamResponse) {
if (streamResponse == null || streamResponse.trim().isEmpty()) {
return null;
}
try {
// 流式响应通常是多行JSON,每行一个事件
String[] lines = streamResponse.split("\n");
StringBuilder finalContent = new StringBuilder();
for (String line : lines) {
line = line.trim();
if (line.isEmpty() || !line.startsWith("{")) {
continue;
}
try {
// 这里应该根据Coze实际的流式响应格式来解析
// 暂时简单处理,实际使用时需要根据API文档调整
if (line.contains("\"content\"")) {
// 提取content字段
int contentStart = line.indexOf("\"content\":\"") + 11;
int contentEnd = line.indexOf("\"", contentStart);
if (contentStart > 10 && contentEnd > contentStart) {
String content = line.substring(contentStart, contentEnd);
finalContent.append(content);
}
}
} catch (Exception e) {
log.debug("解析流式响应行失败: {}", line, e);
}
}
return finalContent.length() > 0 ? finalContent.toString() : null;
} catch (Exception e) {
log.error("解析流式响应失败: {}", e.getMessage(), e);
return null;
}
}
/**
* 从Coze响应中提取内容
*/
private String extractContentFromCozeResponse(Map<String, Object> data) {
try {
// 根据Coze API响应格式解析内容
if (data.get("messages") != null) {
List<Map<String, Object>> messages = (List<Map<String, Object>>) data.get("messages");
for (Map<String, Object> message : messages) {
if ("assistant".equals(message.get("role")) && "answer".equals(message.get("type"))) {
return (String) message.get("content");
}
}
}
return "抱歉,我现在无法理解您的消息。";
} catch (Exception e) {
log.error("解析Coze响应失败: {}", e.getMessage());
return "抱歉,响应解析出现问题。";
}
}
/**
* 构建情绪分析提示词
*/
private String buildEmotionAnalysisPrompt(String text) {
return String.format("""
请对以下文本进行情绪分析,并以JSON格式返回结果:
文本: "%s"
请返回以下格式的JSON:
{
"primaryEmotion": "主要情绪",
"intensity": 0.0-1.0的强度值,
"polarity": "positive/negative/neutral",
"confidence": 0.0-1.0的置信度,
"emotions": [
{"emotion": "情绪名称", "score": 得分, "description": "描述"}
],
"keywords": ["关键词1", "关键词2"],
"suggestion": "建议"
}
""", text);
}
/**
* 解析情绪分析结果
*/
private EmotionAnalysisResponse parseEmotionAnalysisResult(String result) {
// 这里应该解析AI返回的JSON结果
// 为了简化,先返回一个模拟结果
EmotionAnalysisResponse response = new EmotionAnalysisResponse();
response.setPrimaryEmotion("中性");
response.setIntensity(0.5);
response.setPolarity("neutral");
response.setConfidence(0.8);
response.setAnalysisTime(LocalDateTime.now());
List<EmotionAnalysisResponse.EmotionScore> emotions = new ArrayList<>();
EmotionAnalysisResponse.EmotionScore score = new EmotionAnalysisResponse.EmotionScore();
score.setEmotion("中性");
score.setScore(0.5);
score.setDescription("情绪相对平稳");
emotions.add(score);
response.setEmotions(emotions);
response.setKeywords(List.of("情绪", "分析"));
response.setSuggestion("保持当前的情绪状态");
return response;
}
}
@@ -1,318 +0,0 @@
package com.emotionmuseum.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.emotionmuseum.ai.entity.Conversation;
import com.emotionmuseum.ai.entity.Message;
import com.emotionmuseum.ai.entity.CozeApiCall;
import com.emotionmuseum.ai.mapper.ConversationMapper;
import com.emotionmuseum.ai.mapper.MessageMapper;
import com.emotionmuseum.ai.mapper.CozeApiCallMapper;
import com.emotionmuseum.ai.service.ConversationDbService;
import com.emotionmuseum.common.dto.PageQuery;
import com.emotionmuseum.common.entity.BaseEntity;
import com.emotionmuseum.common.util.SnowflakeIdGenerator;
import cn.hutool.core.util.IdUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 会话数据库服务实现类
*
* @author huazhongmin
* @since 2025-07-12
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ConversationDbServiceImpl implements ConversationDbService {
private final ConversationMapper conversationMapper;
private final MessageMapper messageMapper;
private final CozeApiCallMapper cozeApiCallMapper;
private final SnowflakeIdGenerator snowflakeIdGenerator;
private final ObjectMapper objectMapper;
@Override
@Transactional
public Conversation saveConversation(Conversation conversation) {
log.info("保存会话: conversationId={}, userId={}", conversation.getId(), conversation.getUserId());
// 手动设置ID,确保不为空
if (conversation.getId() == null || conversation.getId().isEmpty()) {
conversation.setId(String.valueOf(snowflakeIdGenerator.nextId()));
}
if (conversation.getStartTime() == null) {
conversation.setStartTime(LocalDateTime.now());
}
if (conversation.getStatus() == null) {
conversation.setStatus("active");
}
if (conversation.getMessageCount() == null) {
conversation.setMessageCount(0);
}
if (conversation.getCreateTime() == null) {
conversation.setCreateTime(LocalDateTime.now());
}
if (conversation.getUpdateTime() == null) {
conversation.setUpdateTime(LocalDateTime.now());
}
conversationMapper.insert(conversation);
return conversation;
}
@Override
public Conversation getConversationById(String conversationId) {
log.debug("查询会话: conversationId={}", conversationId);
return conversationMapper.selectById(conversationId);
}
@Override
public List<Conversation> getConversationsByUserId(String userId, PageQuery pageQuery) {
log.debug("查询用户会话列表: userId={}, pageNum={}, pageSize={}",
userId, pageQuery.getPageNum(), pageQuery.getPageSize());
LambdaQueryWrapper<Conversation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Conversation::getUserId, userId)
.orderByDesc(Conversation::getUpdateTime);
// 简单分页实现
int offset = (pageQuery.getPageNum() - 1) * pageQuery.getPageSize();
wrapper.last("LIMIT " + pageQuery.getPageSize() + " OFFSET " + offset);
return conversationMapper.selectList(wrapper);
}
@Override
public List<Conversation> getActiveConversationsByUserId(String userId) {
log.debug("查询用户活跃会话: userId={}", userId);
return conversationMapper.selectActiveConversationsByUserId(userId);
}
@Override
@Transactional
public boolean updateConversationStatus(String conversationId, String status) {
log.info("更新会话状态: conversationId={}, status={}", conversationId, status);
LambdaUpdateWrapper<Conversation> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Conversation::getId, conversationId)
.set(Conversation::getStatus, status)
.set(Conversation::getUpdateTime, LocalDateTime.now());
if ("ended".equals(status)) {
wrapper.set(Conversation::getEndTime, LocalDateTime.now());
}
return conversationMapper.update(null, wrapper) > 0;
}
@Override
@Transactional
public boolean updateConversationActiveTime(String conversationId) {
log.debug("更新会话活跃时间: conversationId={}", conversationId);
// 获取当前消息数量
Integer messageCount = getMessageCount(conversationId);
return conversationMapper.updateLastActiveTime(conversationId, LocalDateTime.now(), messageCount) > 0;
}
@Override
@Transactional
public Message saveMessage(Message message) {
log.info("保存消息: conversationId={}, sender={}, type={}",
message.getConversationId(), message.getSender(), message.getType());
// 设置消息ID
if (message.getId() == null || message.getId().isEmpty()) {
message.setId(String.valueOf(snowflakeIdGenerator.nextId()));
}
if (message.getTimestamp() == null) {
message.setTimestamp(LocalDateTime.now());
}
if (message.getIsRead() == null) {
message.setIsRead(0);
}
// 手动设置通用字段
LocalDateTime now = LocalDateTime.now();
if (message.getCreateTime() == null) {
message.setCreateTime(now);
}
if (message.getUpdateTime() == null) {
message.setUpdateTime(now);
}
messageMapper.insert(message);
// 更新会话活跃时间
updateConversationActiveTime(message.getConversationId());
return message;
}
@Override
public List<Message> getMessagesByConversationId(String conversationId, PageQuery pageQuery) {
log.debug("查询会话消息: conversationId={}, pageNum={}, pageSize={}",
conversationId, pageQuery.getPageNum(), pageQuery.getPageSize());
int offset = (pageQuery.getPageNum() - 1) * pageQuery.getPageSize();
return messageMapper.selectMessagesByConversationId(conversationId, pageQuery.getPageSize(), offset);
}
@Override
public List<Message> getLatestMessages(String conversationId, Integer limit) {
log.debug("查询最新消息: conversationId={}, limit={}", conversationId, limit);
return messageMapper.selectLatestMessagesByConversationId(conversationId, limit);
}
@Override
@Transactional
public boolean markMessageAsRead(String messageId) {
log.debug("标记消息已读: messageId={}", messageId);
return messageMapper.markMessageAsRead(messageId) > 0;
}
@Override
@Transactional
public boolean markConversationMessagesAsRead(String conversationId) {
log.info("标记会话消息已读: conversationId={}", conversationId);
return messageMapper.markConversationMessagesAsRead(conversationId) > 0;
}
@Override
public Integer getMessageCount(String conversationId) {
return messageMapper.countMessagesByConversationId(conversationId);
}
@Override
public Integer getUnreadMessageCount(String conversationId) {
return messageMapper.countUnreadMessages(conversationId);
}
@Override
@Transactional
public boolean deleteConversation(String conversationId) {
log.info("删除会话: conversationId={}", conversationId);
// 软删除会话
LambdaUpdateWrapper<Conversation> conversationWrapper = new LambdaUpdateWrapper<>();
conversationWrapper.eq(Conversation::getId, conversationId)
.setSql("is_deleted = 1")
.set(Conversation::getUpdateTime, LocalDateTime.now());
// 软删除相关消息
LambdaUpdateWrapper<Message> messageWrapper = new LambdaUpdateWrapper<>();
messageWrapper.eq(Message::getConversationId, conversationId)
.setSql("is_deleted = 1")
.set(Message::getUpdateTime, LocalDateTime.now());
boolean conversationDeleted = conversationMapper.update(null, conversationWrapper) > 0;
boolean messagesDeleted = messageMapper.update(null, messageWrapper) >= 0; // 可能没有消息
return conversationDeleted && messagesDeleted;
}
@Override
public Conversation getConversationByCozeId(String cozeConversationId) {
LambdaQueryWrapper<Conversation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Conversation::getCozeConversationId, cozeConversationId)
.last("AND is_deleted = 0");
return conversationMapper.selectOne(wrapper);
}
@Override
public boolean updateConversationCozeInfo(String conversationId, String cozeConversationId, String botId,
String workflowId) {
LambdaUpdateWrapper<Conversation> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Conversation::getId, conversationId)
.set(Conversation::getCozeConversationId, cozeConversationId)
.set(Conversation::getBotId, botId)
.set(Conversation::getWorkflowId, workflowId)
.set(Conversation::getUpdateTime, LocalDateTime.now());
return conversationMapper.update(null, wrapper) > 0;
}
@Override
public boolean updateMessageCozeInfo(String messageId, String cozeChatId, String cozeMessageId, String status) {
LambdaUpdateWrapper<Message> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Message::getId, messageId)
.set(Message::getCozeChatId, cozeChatId)
.set(Message::getCozeMessageId, cozeMessageId)
.set(Message::getStatus, status)
.set(Message::getUpdateTime, LocalDateTime.now());
return messageMapper.update(null, wrapper) > 0;
}
@Override
@Transactional
public CozeApiCall saveCozeApiCall(CozeApiCall cozeApiCall) {
if (cozeApiCall.getId() == null) {
cozeApiCall.setId(IdUtil.fastSimpleUUID());
}
// 手动设置通用字段
LocalDateTime now = LocalDateTime.now();
if (cozeApiCall.getCreateTime() == null) {
cozeApiCall.setCreateTime(now);
}
if (cozeApiCall.getUpdateTime() == null) {
cozeApiCall.setUpdateTime(now);
}
cozeApiCallMapper.insert(cozeApiCall);
return cozeApiCall;
}
@Override
public boolean updateCozeApiCallStatus(String callId, String status, Object responseBody, String errorMessage) {
LocalDateTime now = LocalDateTime.now();
if (errorMessage != null) {
// 有错误信息时使用错误更新方法
return cozeApiCallMapper.updateStatusWithErrorById(callId, status, now, now, errorMessage) > 0;
} else {
// 正常响应时使用响应更新方法,将对象序列化为JSON字符串
String responseBodyStr = null;
if (responseBody != null) {
try {
responseBodyStr = objectMapper.writeValueAsString(responseBody);
} catch (Exception e) {
log.error("序列化响应体失败: {}", e.getMessage());
responseBodyStr = responseBody.toString();
}
}
return cozeApiCallMapper.updateStatusById(callId, status, now, now, responseBodyStr) > 0;
}
}
@Override
public CozeApiCall getCozeApiCallById(String callId) {
return cozeApiCallMapper.selectById(callId);
}
@Override
public boolean updateCozeApiCall(CozeApiCall cozeApiCall) {
cozeApiCall.setUpdateTime(LocalDateTime.now());
return cozeApiCallMapper.updateById(cozeApiCall) > 0;
}
@Override
public List<Message> getMessagesByIds(List<String> messageIds) {
if (messageIds == null || messageIds.isEmpty()) {
return new ArrayList<>();
}
return messageMapper.selectBatchIds(messageIds);
}
}
@@ -1,298 +0,0 @@
package com.emotionmuseum.ai.service.impl;
import com.emotionmuseum.ai.config.FeatureConfig;
import com.emotionmuseum.ai.dto.*;
import com.emotionmuseum.ai.entity.Conversation;
import com.emotionmuseum.ai.entity.Message;
import com.emotionmuseum.ai.service.AiChatService;
import com.emotionmuseum.ai.service.ConversationDbService;
import com.emotionmuseum.ai.service.GuestChatService;
import com.emotionmuseum.ai.service.GuestUserService;
import com.emotionmuseum.common.result.Result;
import com.emotionmuseum.common.dto.PageQuery;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 访客聊天服务实现
*
* @author huazhongmin
* @since 2025-07-13
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GuestChatServiceImpl implements GuestChatService {
private final GuestUserService guestUserService;
private final AiChatService aiChatService;
private final ConversationDbService conversationDbService;
private final FeatureConfig featureConfig;
@Override
public Result<GuestChatResponse> guestChat(GuestChatRequest request) {
log.info("处理访客聊天请求: IP={}, Message={}", request.getClientIp(), request.getMessage());
try {
// 1. 获取或创建访客用户
GuestUserInfo guestUser = guestUserService.getOrCreateGuestUser(
request.getClientIp(), request.getUserAgent());
// 2. 处理会话
String conversationId = request.getConversationId();
boolean isNewConversation = false;
if (!StringUtils.hasText(conversationId)) {
// 创建新会话
CreateConversationRequest createRequest = new CreateConversationRequest();
createRequest.setUserId(guestUser.getGuestUserId());
createRequest.setTitle(request.getTitle() != null ? request.getTitle() : "访客会话");
createRequest.setType("guest_chat");
CreateConversationResponse createResponse = aiChatService.createConversation(createRequest);
conversationId = createResponse.getConversationId().toString();
isNewConversation = true;
log.info("为访客用户创建新会话: guestUserId={}, conversationId={}",
guestUser.getGuestUserId(), conversationId);
}
// 3. 发送消息
ChatRequest chatRequest = new ChatRequest();
chatRequest.setUserId(guestUser.getGuestUserId());
chatRequest.setConversationId(conversationId);
chatRequest.setMessage(request.getMessage());
chatRequest.setType(request.getMessageType());
chatRequest.setNeedEmotionAnalysis(true);
ChatResponse chatResponse = aiChatService.chat(chatRequest);
// 4. 更新访客用户统计
try {
((GuestUserServiceImpl) guestUserService).incrementMessageCount(guestUser.getGuestUserId(), 2); // 用户消息+AI回复
guestUserService.updateLastActiveTime(guestUser.getGuestUserId());
} catch (Exception e) {
log.warn("更新访客用户统计失败: {}", e.getMessage());
}
// 5. 构建响应
GuestChatResponse response = GuestChatResponse.builder()
.guestUserId(guestUser.getGuestUserId())
.guestNickname(guestUser.getNickname())
.conversationId(conversationId)
.conversationTitle(request.getTitle())
.userMessageId(chatResponse.getMessageId())
.aiMessageId(chatResponse.getMessageId())
.userMessage(request.getMessage())
.aiReply(chatResponse.getContent())
.timestamp(chatResponse.getTimestamp())
.conversationStatus("active")
.isNewConversation(isNewConversation)
.build();
// 6. 添加情绪分析结果(如果有)
if (chatResponse.getEmotionAnalysis() != null) {
response.setEmotionAnalysis(GuestChatResponse.EmotionAnalysisResult.builder()
.primaryEmotion(chatResponse.getEmotionAnalysis().getPrimaryEmotion())
.emotionScore(chatResponse.getEmotionAnalysis().getIntensity())
.confidence(chatResponse.getEmotionAnalysis().getConfidence())
.emotionTrend("stable")
.build());
}
// 7. 添加Token使用情况(如果有)
if (chatResponse.getUsage() != null) {
response.setTokenUsage(GuestChatResponse.TokenUsage.builder()
.promptTokens(chatResponse.getUsage().getPromptTokens())
.completionTokens(chatResponse.getUsage().getCompletionTokens())
.totalTokens(chatResponse.getUsage().getTotalTokens())
.build());
}
log.info("访客聊天处理成功: guestUserId={}, conversationId={}",
guestUser.getGuestUserId(), conversationId);
return Result.success(response);
} catch (Exception e) {
log.error("访客聊天处理失败", e);
return Result.error("聊天处理失败: " + e.getMessage());
}
}
@Override
public Result<List<ConversationListResponse>> getGuestConversations(String clientIp, Integer pageNum,
Integer pageSize) {
try {
// 根据IP获取访客用户
GuestUserInfo guestUser = guestUserService.getOrCreateGuestUser(clientIp, null);
// 获取访客的会话列表
PageQuery pageQuery = new PageQuery();
pageQuery.setPageNum(pageNum);
pageQuery.setPageSize(pageSize);
List<Conversation> conversations = conversationDbService.getConversationsByUserId(
guestUser.getGuestUserId(), pageQuery);
List<ConversationListResponse> responseList = conversations.stream()
.map(this::convertToConversationListResponse)
.collect(Collectors.toList());
return Result.success(responseList);
} catch (Exception e) {
log.error("获取访客会话列表失败", e);
return Result.error("获取会话列表失败: " + e.getMessage());
}
}
@Override
public Result<List<MessageListResponse>> getGuestConversationMessages(String conversationId, String clientIp,
Integer pageNum, Integer pageSize) {
try {
// 验证会话是否属于该访客用户
Conversation conversation = conversationDbService.getConversationById(conversationId);
if (conversation == null) {
return Result.error("会话不存在");
}
// 验证IP是否匹配
if (!clientIp.equals(conversation.getClientIp())) {
log.warn("访客IP不匹配: 请求IP={}, 会话IP={}", clientIp, conversation.getClientIp());
return Result.error("无权访问该会话");
}
// 获取消息列表
PageQuery pageQuery = new PageQuery();
pageQuery.setPageNum(pageNum);
pageQuery.setPageSize(pageSize);
List<Message> messages = conversationDbService.getMessagesByConversationId(
conversationId, pageQuery);
List<MessageListResponse> responseList = messages.stream()
.map(this::convertToMessageListResponse)
.collect(Collectors.toList());
return Result.success(responseList);
} catch (Exception e) {
log.error("获取访客会话消息失败", e);
return Result.error("获取会话消息失败: " + e.getMessage());
}
}
@Override
public Result<Void> endGuestConversation(String conversationId, String clientIp) {
try {
// 验证会话是否属于该访客用户
Conversation conversation = conversationDbService.getConversationById(conversationId);
if (conversation == null) {
return Result.error("会话不存在");
}
// 验证IP是否匹配
if (!clientIp.equals(conversation.getClientIp())) {
return Result.error("无权操作该会话");
}
// 结束会话
conversationDbService.updateConversationStatus(conversationId, "ended");
return Result.success();
} catch (Exception e) {
log.error("结束访客会话失败", e);
return Result.error("结束会话失败: " + e.getMessage());
}
}
@Override
public Result<GuestUserInfo> getOrCreateGuestUser(String clientIp, String userAgent) {
try {
GuestUserInfo guestUser = guestUserService.getOrCreateGuestUser(clientIp, userAgent);
return Result.success(guestUser);
} catch (Exception e) {
log.error("获取访客用户信息失败", e);
return Result.error("获取用户信息失败: " + e.getMessage());
}
}
@Override
public Result<EmotionAnalysisResponse> analyzeGuestEmotion(EmotionAnalysisRequest request, String clientIp) {
// 检查情绪分析功能是否启用
if (!featureConfig.getEmotionAnalysis().isEnabled()) {
log.warn("访客情绪分析功能已禁用: IP={}", clientIp);
return Result.error("情绪分析功能暂时不可用");
}
try {
// 获取访客用户信息
GuestUserInfo guestUser = guestUserService.getOrCreateGuestUser(clientIp, null);
// 设置用户ID为访客用户ID
request.setUserId(guestUser.getGuestUserId());
// 调用情绪分析服务
EmotionAnalysisResponse response = aiChatService.analyzeEmotion(request);
return Result.success(response);
} catch (Exception e) {
log.error("访客情绪分析失败", e);
return Result.error("情绪分析失败: " + e.getMessage());
}
}
/**
* 转换为会话列表响应
*/
private ConversationListResponse convertToConversationListResponse(Conversation conversation) {
return ConversationListResponse.builder()
.conversationId(conversation.getId())
.title(conversation.getTitle())
.type(conversation.getType())
.status(conversation.getStatus())
.userId(conversation.getUserId())
.userType(conversation.getUserType())
.messageCount(conversation.getMessageCount())
.lastActiveTime(conversation.getLastActiveTime())
.createTime(conversation.getCreateTime())
.primaryEmotion(conversation.getPrimaryEmotion())
.emotionIntensity(
conversation.getEmotionIntensity() != null ? conversation.getEmotionIntensity().doubleValue()
: null)
.cozeConversationId(conversation.getCozeConversationId())
.build();
}
/**
* 转换为消息列表响应
*/
private MessageListResponse convertToMessageListResponse(Message message) {
return MessageListResponse.builder()
.messageId(message.getId())
.conversationId(message.getConversationId())
.content(message.getContent())
.type(message.getType())
.sender(message.getSender())
.timestamp(message.getTimestamp())
.status(message.getStatus())
.emotionType(message.getEmotionType())
.emotionScore(message.getEmotionScore())
.emotionConfidence(message.getEmotionConfidence())
.isRead(message.getIsRead())
.cozeChatId(message.getCozeChatId())
.cozeMessageId(message.getCozeMessageId())
.userId(message.getUserId())
.userType(message.getUserType())
.build();
}
}
@@ -1,180 +0,0 @@
package com.emotionmuseum.ai.service.impl;
import com.emotionmuseum.ai.dto.GuestUserInfo;
import com.emotionmuseum.ai.entity.GuestUser;
import com.emotionmuseum.ai.mapper.GuestUserMapper;
import com.emotionmuseum.ai.service.GuestUserService;
import com.emotionmuseum.common.util.SnowflakeIdGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.Random;
/**
* 访客用户服务实现
*
* @author huazhongmin
* @since 2025-07-13
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GuestUserServiceImpl implements GuestUserService {
private final GuestUserMapper guestUserMapper;
private final SnowflakeIdGenerator snowflakeIdGenerator;
private final Random random = new Random();
@Override
public GuestUserInfo getOrCreateGuestUser(String ipAddress, String userAgent) {
log.info("获取或创建访客用户, IP: {}, UserAgent: {}", ipAddress, userAgent);
// 先尝试根据IP查找现有访客用户
GuestUser existingUser = guestUserMapper.findByIpAddress(ipAddress);
if (existingUser != null) {
// 更新最后活跃时间
updateLastActiveTime(existingUser.getGuestUserId());
log.info("找到现有访客用户: {}", existingUser.getGuestUserId());
return convertToDto(existingUser);
}
// 创建新的访客用户
String guestUserId = generateGuestUserId(ipAddress);
GuestUser newUser = new GuestUser();
// 手动设置ID,确保不为空
newUser.setId(String.valueOf(snowflakeIdGenerator.nextId()));
newUser.setGuestUserId(guestUserId);
newUser.setIpAddress(ipAddress);
newUser.setUserAgent(userAgent);
newUser.setNickname(generateGuestNickname());
newUser.setAvatar(generateGuestAvatar());
newUser.setLastActiveTime(LocalDateTime.now());
newUser.setConversationCount(0);
newUser.setMessageCount(0);
newUser.setCreateBy("system");
newUser.setUpdateBy("system");
try {
guestUserMapper.insert(newUser);
log.info("创建新访客用户成功: {}", guestUserId);
return convertToDto(newUser);
} catch (Exception e) {
log.error("创建访客用户失败", e);
throw new RuntimeException("创建访客用户失败: " + e.getMessage());
}
}
@Override
public GuestUserInfo getGuestUserById(String guestUserId) {
if (!isGuestUser(guestUserId)) {
return null;
}
GuestUser guestUser = guestUserMapper.findByGuestUserId(guestUserId);
return guestUser != null ? convertToDto(guestUser) : null;
}
@Override
public void updateLastActiveTime(String guestUserId) {
if (isGuestUser(guestUserId)) {
guestUserMapper.updateLastActiveTime(guestUserId);
}
}
@Override
public boolean isGuestUser(String userId) {
return StringUtils.hasText(userId) && userId.startsWith("guest_");
}
@Override
public String generateGuestUserId(String ipAddress) {
try {
// 使用IP地址和时间戳生成唯一ID
String input = ipAddress + "_" + System.currentTimeMillis() + "_" + random.nextInt(10000);
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return "guest_" + sb.toString().substring(0, 16);
} catch (Exception e) {
log.error("生成访客用户ID失败", e);
// 降级方案
return "guest_" + System.currentTimeMillis() + "_" + random.nextInt(10000);
}
}
/**
* 生成访客昵称
*/
private String generateGuestNickname() {
String[] adjectives = { "神秘的", "友善的", "智慧的", "温暖的", "勇敢的", "优雅的", "活泼的", "宁静的" };
String[] nouns = { "访客", "旅行者", "探索者", "朋友", "伙伴", "客人", "用户", "来访者" };
String adjective = adjectives[random.nextInt(adjectives.length)];
String noun = nouns[random.nextInt(nouns.length)];
int number = random.nextInt(9999) + 1;
return adjective + noun + number;
}
/**
* 生成访客头像
*/
private String generateGuestAvatar() {
// 使用默认头像或随机头像
String[] avatars = {
"/images/avatars/guest1.png",
"/images/avatars/guest2.png",
"/images/avatars/guest3.png",
"/images/avatars/guest4.png",
"/images/avatars/guest5.png"
};
return avatars[random.nextInt(avatars.length)];
}
/**
* 转换为DTO
*/
private GuestUserInfo convertToDto(GuestUser guestUser) {
return GuestUserInfo.builder()
.guestUserId(guestUser.getGuestUserId())
.ipAddress(guestUser.getIpAddress())
.userAgent(guestUser.getUserAgent())
.nickname(guestUser.getNickname())
.avatar(guestUser.getAvatar())
.createTime(guestUser.getCreateTime())
.lastActiveTime(guestUser.getLastActiveTime())
.isGuest(true)
.conversationCount(guestUser.getConversationCount())
.messageCount(guestUser.getMessageCount())
.build();
}
/**
* 增加会话数量
*/
public void incrementConversationCount(String guestUserId) {
if (isGuestUser(guestUserId)) {
guestUserMapper.incrementConversationCount(guestUserId);
}
}
/**
* 增加消息数量
*/
public void incrementMessageCount(String guestUserId, int count) {
if (isGuestUser(guestUserId)) {
guestUserMapper.incrementMessageCount(guestUserId, count);
}
}
}
@@ -1,89 +0,0 @@
# AI服务 Docker环境配置
server:
port: 9002
spring:
application:
name: emotion-ai
profiles:
active: docker
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: public
group: DEFAULT_GROUP
config:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
file-extension: yml
namespace: public
group: DEFAULT_GROUP
datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: EmotionAiHikariCP
minimum-idle: 5
maximum-pool-size: 20
auto-commit: true
idle-timeout: 30000
max-lifetime: 1800000
connection-timeout: 30000
data:
redis:
host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379}
password:
database: 1
timeout: 6000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
# MyBatis Plus配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
call-setters-on-nulls: true
jdbc-type-for-null: 'null'
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_uuid
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
banner: false
# Coze API配置
coze:
api:
base-url: https://api.coze.cn
token: ${COZE_API_TOKEN:your-coze-api-token}
bot-id: 7523042446285439016
workflow-id: 7523047462895796287
timeout: 30000
# 日志配置
logging:
level:
com.emotionmuseum: DEBUG
com.emotionmuseum.ai.mapper: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
# 管理端点
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
@@ -1,82 +0,0 @@
# 本地开发环境配置
spring:
cloud:
nacos:
discovery:
server-addr: localhost:8848
namespace:
group: DEFAULT_GROUP
enabled: true
username: nacos
password: Peanut2817*#
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: Peanut2817*#
# 数据源配置
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
# 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
model:
temperature: 0.7
max-tokens: 1000
top-p: 0.9
frequency-penalty: 0.0
presence-penalty: 0.0
# 功能开关配置
features:
emotion-analysis:
enabled: false
auto-analyze: false
chat:
enabled: true
stream: false
# 日志配置
logging:
level:
com.emotionmuseum: debug
com.baomidou.mybatisplus: debug
com.alibaba.nacos: info
file:
name: logs/emotion-ai-local.log
@@ -1,55 +0,0 @@
# 生产环境配置
spring:
cloud:
nacos:
discovery:
server-addr: 101.200.208.45: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: 101.200.208.45: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://101.200.208.45:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: root
password: EmotionMuseum2025*#
# Redis配置
data:
redis:
host: 101.200.208.45
port: 6379
password: EmotionMuseum2025*#
database: 0
# 日志配置
logging:
level:
com.emotionmuseum: warn
com.baomidou.mybatisplus: warn
com.alibaba.nacos: error
file:
name: logs/emotion-ai-prod.log
@@ -1,55 +0,0 @@
# 测试环境配置
spring:
cloud:
nacos:
discovery:
server-addr: 101.200.208.45: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: 101.200.208.45: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://101.200.208.45:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: root
password: EmotionMuseum2025*#
# Redis配置
data:
redis:
host: 101.200.208.45
port: 6379
password: EmotionMuseum2025*#
database: 0
# 日志配置
logging:
level:
com.emotionmuseum: info
com.baomidou.mybatisplus: info
com.alibaba.nacos: warn
file:
name: logs/emotion-ai-test.log
@@ -1,128 +0,0 @@
server:
port: 19002
spring:
application:
name: emotion-ai
# 配置文件激活
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
# 允许Bean覆盖和循环引用
main:
allow-bean-definition-overriding: true
allow-circular-references: true
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:emotion_museum}?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:123456}
hikari:
minimum-idle: 5
maximum-pool-size: 20
idle-timeout: 30000
max-lifetime: 1800000
connection-timeout: 30000
connection-test-query: SELECT 1
# Redis配置
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 1
timeout: 10000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
# Nacos配置
cloud:
nacos:
discovery:
server-addr: ${NACOS_HOST:localhost}:${NACOS_PORT:8848}
namespace: ${NACOS_NAMESPACE:}
group: ${NACOS_GROUP:DEFAULT_GROUP}
enabled: ${NACOS_DISCOVERY_ENABLED:false}
config:
server-addr: ${NACOS_HOST:localhost}:${NACOS_PORT:8848}
namespace: ${NACOS_NAMESPACE:}
group: ${NACOS_GROUP:DEFAULT_GROUP}
file-extension: yml
enabled: ${NACOS_CONFIG_ENABLED:false}
# Coze平台配置
coze:
base-url: ${COZE_BASE_URL:https://api.coze.cn}
api-key: ${COZE_API_KEY:your-coze-api-key}
bot-id: ${COZE_BOT_ID:7523042446285439016}
workflow-id: ${COZE_WORKFLOW_ID:7523047462895796287}
user-id: ${COZE_USER_ID:emotion-museum-user}
token: pat_GCR4qKzqpf90wMCvKsldMrB18KG3QsLDci65bZthssKsbLxu8X70BKYumleDcabO
timeout: 60
max-retries: 3
stream: false
model:
temperature: 0.7
max-tokens: 1000
top-p: 0.9
frequency-penalty: 0.0
presence-penalty: 0.0
# 功能开关配置
features:
emotion-analysis:
enabled: ${EMOTION_ANALYSIS_ENABLED:false} # 暂时禁用情绪分析
auto-analyze: false # 禁用自动情绪分析
chat:
enabled: true
stream: false
# 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_UUID
logic-delete-field: is_deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:/mapper/**/*.xml
# 监控配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
export:
prometheus:
enabled: true
# 日志配置
logging:
file:
path: /data/logs/emotion-museum/ai
level:
com.emotionmuseum: debug
com.baomidou.mybatisplus: debug
com.emotionmuseum.common.handler.MetaObjectHandler: debug
com.emotionmuseum.common.interceptor.UserContextInterceptor: debug
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
@@ -1,30 +0,0 @@
package com.emotionmuseum.ai.service;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 消息拆分功能测试
*/
@SpringBootTest
public class MessageSplitTest {
@Test
public void testMessageSplit() {
// 测试消息拆分逻辑
String aiContent = "这是第一段内容,介绍了基本功能。\n\n这是第二段内容,详细说明了聊天功能。\n\n这是第三段内容,介绍了情感分析功能。";
// 按\n\n拆分消息
String[] messageParts = aiContent.split("\\n\\n");
System.out.println("原始消息: " + aiContent);
System.out.println("拆分后的消息数量: " + messageParts.length);
for (int i = 0; i < messageParts.length; i++) {
String part = messageParts[i].trim();
if (!part.isEmpty()) {
System.out.println("消息片段 " + (i + 1) + ": " + part);
}
}
}
}
-48
View File
@@ -1,48 +0,0 @@
# 认证服务Dockerfile
FROM openjdk:17-jdk-alpine
# 设置工作目录
WORKDIR /app
# 安装必要的工具
RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 复制Maven构建文件
COPY pom.xml ./
COPY emotion-common ./emotion-common
COPY emotion-auth ./emotion-auth
# 安装Maven
RUN apk add --no-cache maven
# 构建应用
RUN mvn clean package -DskipTests -pl emotion-auth -am
# 创建运行用户
RUN addgroup -g 1000 emotion && \
adduser -D -s /bin/sh -u 1000 -G emotion emotion
# 复制jar文件
RUN cp emotion-auth/target/emotion-auth-*.jar app.jar
# 设置文件权限
RUN chown -R emotion:emotion /app
# 切换到非root用户
USER emotion
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:19008/actuator/health || exit 1
# 暴露端口
EXPOSE 19008
# 启动命令
ENTRYPOINT ["java", "-jar", \
"-Xms512m", "-Xmx1024m", \
"-Djava.security.egd=file:/dev/./urandom", \
"-Dspring.profiles.active=local", \
"app.jar"]
-25
View File
@@ -1,25 +0,0 @@
<?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>auth</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>auth-api</artifactId>
<name>auth-api</name>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>
@@ -1,14 +0,0 @@
package com.emotionmuseum.auth.api.client;
import com.emotionmuseum.common.result.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "auth")
public interface AuthClient {
@GetMapping("/auth/health")
Result<Boolean> health();
}
-226
View File
@@ -1,226 +0,0 @@
#!/bin/bash
# emotion-auth 单独部署脚本
# 作者: emotion-museum
# 日期: 2025-07-18
set -e
# 配置变量
SERVICE_NAME="emotion-auth"
SERVICE_PORT=""
REMOTE_HOST="'root@101.200.208.45'"
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@101.200.208.45' "echo 'Connection successful'" > /dev/null 2>&1; then
log_success "远程服务器连接正常"
else
log_error "无法连接到远程服务器 'root@101.200.208.45'"
exit 1
fi
}
# 构建服务
build_service() {
log_info "构建服务: $SERVICE_NAME"
# 构建父项目依赖
cd ..
mvn clean install -DskipTests -q
cd emotion-auth
# 构建当前服务
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@101.200.208.45' "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@101.200.208.45' "
mkdir -p $REMOTE_BUILD_DIR
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
mkdir -p /data/logs/emotion-museum
"
# 删除旧jar包
log_info "删除远程旧jar包"
ssh 'root@101.200.208.45' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
# 上传新jar包
log_info "上传jar包"
if scp "$jar_file" 'root@101.200.208.45':$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@101.200.208.45' "
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@101.200.208.45' "docker network create emotion-network 2>/dev/null || true"
# 构建镜像
log_info "构建Docker镜像"
ssh 'root@101.200.208.45' "
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@101.200.208.45' "
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=101.200.208.45 \\
-e MYSQL_PORT=3306 \\
-e MYSQL_DATABASE=emotion_museum \\
-e MYSQL_USERNAME=root \\
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
-e REDIS_HOST=101.200.208.45 \\
-e REDIS_PORT=6379 \\
-e REDIS_PASSWORD= \\
-e REDIS_DATABASE=0 \\
-e NACOS_SERVER_ADDR=101.200.208.45:8848 \\
-e NACOS_USERNAME=nacos \\
-e NACOS_PASSWORD='Peanut2817*#' \\
--restart unless-stopped \\
${PROJECT_NAME}/${SERVICE_NAME}:latest
"
# 等待启动
log_info "等待服务启动..."
sleep 15
# 检查状态
if ssh 'root@101.200.208.45' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
log_success "服务启动成功"
# 显示日志
log_info "服务日志 最后20行:"
ssh 'root@101.200.208.45' "docker logs --tail 20 ${SERVICE_NAME}"
# 健康检查
log_info "执行健康检查..."
sleep 10
if ssh 'root@101.200.208.45' "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@101.200.208.45' "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://101.200.208.45:$SERVICE_PORT"
}
# 执行主函数
main "$@"
-18
View File
@@ -1,18 +0,0 @@
<?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>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>auth</artifactId>
<packaging>pom</packaging>
<modules>
<module>api</module>
<module>server</module>
</modules>
</project>
-145
View File
@@ -1,145 +0,0 @@
<?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>auth</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>auth-server</artifactId>
<name>auth</name>
<description>情感博物馆认证授权服务</description>
<dependencies>
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>auth-api</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 公共模块 -->
<dependency>
<groupId>com.emotionmuseum</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>
<!-- 第三方登录 -->
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>1.16.5</version>
</dependency>
<!-- 验证码 -->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
<!-- Nacos服务发现/配置中心 -->
<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>
<!-- 监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</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.auth.AuthApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,22 +0,0 @@
package com.emotionmuseum.auth;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
/**
* 认证服务启动类
*
* @author huazhongmin
* @since 2025-07-16
*/
@SpringBootApplication(scanBasePackages = {"com.emotionmuseum"})
@EnableDiscoveryClient
@MapperScan("com.emotionmuseum.auth.mapper")
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}
@@ -1,59 +0,0 @@
package com.emotionmuseum.auth.config;
import com.wf.captcha.ChineseCaptcha;
import com.wf.captcha.GifCaptcha;
import com.wf.captcha.SpecCaptcha;
import com.wf.captcha.base.Captcha;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 验证码配置
*
* @author huazhongmin
* @since 2025-07-15
*/
@Configuration
public class CaptchaConfig {
/**
* 算术验证码 - 暂时禁用,因为Java 23中JavaScript引擎问题
*/
// @Bean("arithmeticCaptcha")
// public Captcha arithmeticCaptcha() {
// ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 48);
// captcha.setLen(2); // 几位数运算,默认是两位
// // captcha.getArithmeticString(); // 获取运算的公式:3+2=?
// return captcha;
// }
/**
* 中文验证码
*/
@Bean("chineseCaptcha")
public Captcha chineseCaptcha() {
ChineseCaptcha captcha = new ChineseCaptcha(130, 48);
captcha.setLen(4); // 几个汉字,默认5个
return captcha;
}
/**
* GIF验证码
*/
@Bean("gifCaptcha")
public Captcha gifCaptcha() {
GifCaptcha captcha = new GifCaptcha(130, 48);
captcha.setLen(4); // 几位数字,默认5位
return captcha;
}
/**
* PNG验证码
*/
@Bean("specCaptcha")
public Captcha specCaptcha() {
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
captcha.setCharType(Captcha.TYPE_DEFAULT); // 设置类型,纯数字、纯字母、字母数字混合
return captcha;
}
}
@@ -1,85 +0,0 @@
package com.emotionmuseum.auth.config;
import me.zhyd.oauth.config.AuthConfig;
import me.zhyd.oauth.request.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 第三方登录配置
*
* @author huazhongmin
* @since 2025-07-15
*/
@Configuration
@ConditionalOnProperty(name = "oauth.enabled", havingValue = "true", matchIfMissing = false)
public class OAuthConfig {
@Value("${oauth.wechat.client-id:}")
private String wechatClientId;
@Value("${oauth.wechat.client-secret:}")
private String wechatClientSecret;
@Value("${oauth.wechat.redirect-uri:}")
private String wechatRedirectUri;
@Value("${oauth.qq.client-id:}")
private String qqClientId;
@Value("${oauth.qq.client-secret:}")
private String qqClientSecret;
@Value("${oauth.qq.redirect-uri:}")
private String qqRedirectUri;
@Value("${oauth.wechat-mp.client-id:}")
private String wechatMpClientId;
@Value("${oauth.wechat-mp.client-secret:}")
private String wechatMpClientSecret;
@Value("${oauth.wechat-mp.redirect-uri:}")
private String wechatMpRedirectUri;
/**
* 微信开放平台登录
*/
@Bean
@ConditionalOnProperty(name = "oauth.wechat.client-id", matchIfMissing = false)
public AuthWeChatOpenRequest weChatOpenRequest() {
return new AuthWeChatOpenRequest(AuthConfig.builder()
.clientId(wechatClientId)
.clientSecret(wechatClientSecret)
.redirectUri(wechatRedirectUri)
.build());
}
/**
* 微信公众平台登录
*/
@Bean
@ConditionalOnProperty(name = "oauth.wechat-mp.client-id", matchIfMissing = false)
public AuthWeChatMpRequest weChatMpRequest() {
return new AuthWeChatMpRequest(AuthConfig.builder()
.clientId(wechatMpClientId)
.clientSecret(wechatMpClientSecret)
.redirectUri(wechatMpRedirectUri)
.build());
}
/**
* QQ登录
*/
@Bean
@ConditionalOnProperty(name = "oauth.qq.client-id", matchIfMissing = false)
public AuthQqRequest qqRequest() {
return new AuthQqRequest(AuthConfig.builder()
.clientId(qqClientId)
.clientSecret(qqClientSecret)
.redirectUri(qqRedirectUri)
.build());
}
}
@@ -1,40 +0,0 @@
package com.emotionmuseum.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类
*
* @author huazhongmin
* @since 2025-07-15
*/
@Configuration
public class RedisConfig {
/**
* 配置RedisTemplate
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 使用String序列化器作为key的序列化器
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
// 使用JSON序列化器作为value的序列化器
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
@@ -1,139 +0,0 @@
package com.emotionmuseum.auth.config;
import com.emotionmuseum.auth.security.JwtAuthenticationFilter;
import com.emotionmuseum.auth.security.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.Collections;
/**
* Spring Security配置类
*
* @author huazhongmin
* @since 2025-07-15
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证提供者
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* 认证管理器
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
/**
* CORS配置
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Collections.singletonList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
/**
* 安全过滤器链
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF
.csrf(AbstractHttpConfigurer::disable)
// 配置CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 配置会话管理
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置授权规则
.authorizeHttpRequests(authz -> authz
// 公开接口
.requestMatchers(
"/auth/register",
"/auth/login",
"/auth/refresh",
"/auth/check-account",
"/auth/check-email",
"/auth/check-phone",
"/auth/resetPassword",
"/captcha/**",
"/oauth/**")
.permitAll()
// 监控和文档接口
.requestMatchers(
"/actuator/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/doc.html",
"/swagger-resources/**",
"/webjars/**",
"/error")
.permitAll()
// 其他接口需要认证
.anyRequest().authenticated())
// 配置认证提供者
.authenticationProvider(authenticationProvider())
// 添加JWT过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@@ -1,128 +0,0 @@
package com.emotionmuseum.auth.controller;
import com.emotionmuseum.common.result.Result;
import com.emotionmuseum.auth.request.LoginRequest;
import com.emotionmuseum.auth.request.RegisterRequest;
import com.emotionmuseum.auth.request.ResetPasswordRequest;
import com.emotionmuseum.auth.service.AuthService;
import com.emotionmuseum.auth.response.LoginResponse;
import com.emotionmuseum.auth.response.ResetPasswordResponse;
import com.emotionmuseum.auth.response.UserInfoResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 认证控制器
*
* @author huazhongmin
* @since 2025-07-16
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Validated
@Tag(name = "用户认证", description = "用户注册、登录、认证管理")
public class AuthController {
private final AuthService authService;
@Operation(summary = "用户注册")
@PostMapping("/register")
public Result<UserInfoResponse> register(@Valid @RequestBody RegisterRequest request) {
log.info("用户注册请求: {}", request.getAccount());
UserInfoResponse response = authService.register(request);
return Result.success("注册成功", response);
}
@Operation(summary = "用户登录")
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
log.info("用户登录请求: {}", request.getAccount());
LoginResponse response = authService.login(request);
return Result.success("登录成功", response);
}
@Operation(summary = "刷新Token")
@PostMapping("/refresh")
public Result<LoginResponse> refreshToken(
@Parameter(description = "刷新Token") @RequestParam String refreshToken) {
log.info("刷新Token请求");
LoginResponse response = authService.refreshToken(refreshToken);
return Result.success("Token刷新成功", response);
}
@Operation(summary = "用户登出")
@PostMapping("/logout")
public Result<Void> logout(
@Parameter(description = "用户ID") @RequestParam String userId) {
log.info("用户登出请求: {}", userId);
authService.logout(userId);
return Result.success();
}
@Operation(summary = "验证Token")
@GetMapping("/validate-token")
public Result<Boolean> validateToken() {
log.info("验证Token请求");
// 如果能到达这里,说明token有效(通过了JWT过滤器)
return Result.success("Token有效", true);
}
@Operation(summary = "获取当前用户信息")
@GetMapping("/user-info")
public Result<UserInfoResponse> getCurrentUserInfo() {
log.info("获取当前用户信息请求");
UserInfoResponse response = authService.getCurrentUserInfo();
return Result.success(response);
}
@Operation(summary = "检查账号是否存在")
@GetMapping("/check-account")
public Result<Boolean> checkAccount(
@Parameter(description = "账号") @RequestParam String account) {
log.info("检查账号是否存在: {}", account);
boolean exists = authService.existsByAccount(account);
return Result.success(exists);
}
@Operation(summary = "检查邮箱是否存在")
@GetMapping("/check-email")
public Result<Boolean> checkEmail(
@Parameter(description = "邮箱") @RequestParam String email) {
log.info("检查邮箱是否存在: {}", email);
boolean exists = authService.existsByEmail(email);
return Result.success(exists);
}
@Operation(summary = "检查手机号是否存在")
@GetMapping("/check-phone")
public Result<Boolean> checkPhone(
@Parameter(description = "手机号") @RequestParam String phone) {
log.info("检查手机号是否存在: {}", phone);
boolean exists = authService.existsByPhone(phone);
return Result.success(exists);
}
/**
* 重置密码(未登录场景)
*
* @param request 重置密码请求(手机号 + 新密码 + 验证码=123456
* @return 重置密码响应
*/
@Operation(summary = "重置密码(手机号+验证码)")
@PostMapping("/resetPassword")
public Result<ResetPasswordResponse> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
log.info("重置密码请求: phone={}", request.getPhone());
ResetPasswordResponse response = authService.resetPassword(request);
return Result.success("重置密码成功", response);
}
}
@@ -1,66 +0,0 @@
package com.emotionmuseum.auth.controller;
import com.emotionmuseum.common.result.Result;
import com.emotionmuseum.auth.response.CaptchaResponse;
import com.emotionmuseum.auth.response.SliderCaptchaResponse;
import com.emotionmuseum.auth.request.SliderCaptchaVerifyRequest;
import com.emotionmuseum.auth.service.CaptchaService;
import com.emotionmuseum.auth.service.SliderCaptchaService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 验证码控制器
*
* @author huazhongmin
* @since 2025-07-15
*/
@Slf4j
@RestController
@RequestMapping("/captcha")
@RequiredArgsConstructor
@Tag(name = "验证码管理", description = "验证码生成和验证接口")
public class CaptchaController {
private final CaptchaService captchaService;
private final SliderCaptchaService sliderCaptchaService;
@Operation(summary = "生成验证码")
@GetMapping("/generate")
public Result<CaptchaResponse> generateCaptcha(
@Parameter(description = "验证码类型", example = "arithmetic") @RequestParam(defaultValue = "arithmetic") String type) {
log.info("生成验证码请求,类型: {}", type);
CaptchaResponse response = captchaService.generateCaptcha(type);
return Result.success(response);
}
@Operation(summary = "验证验证码")
@PostMapping("/verify")
public Result<Boolean> verifyCaptcha(
@Parameter(description = "验证码ID") @RequestParam String captchaId,
@Parameter(description = "验证码") @RequestParam String captcha) {
log.info("验证验证码请求,ID: {}", captchaId);
boolean isValid = captchaService.verifyCaptcha(captchaId, captcha);
return Result.success(isValid);
}
@Operation(summary = "生成滑块验证码")
@GetMapping("/slider/generate")
public Result<SliderCaptchaResponse> generateSliderCaptcha() {
log.info("生成滑块验证码请求");
SliderCaptchaResponse response = sliderCaptchaService.generateSliderCaptcha();
return Result.success(response);
}
@Operation(summary = "验证滑块验证码")
@PostMapping("/slider/verify")
public Result<Boolean> verifySliderCaptcha(@RequestBody SliderCaptchaVerifyRequest request) {
log.info("验证滑块验证码请求,ID: {}", request.getCaptchaId());
boolean isValid = sliderCaptchaService.verifySliderCaptcha(request);
return Result.success(isValid);
}
}
@@ -1,59 +0,0 @@
package com.emotionmuseum.auth.controller;
import com.emotionmuseum.common.result.Result;
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
import com.emotionmuseum.auth.service.OAuthService;
import com.emotionmuseum.auth.vo.LoginResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 第三方登录控制器
*
* @author huazhongmin
* @since 2025-07-15
*/
@Slf4j
@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor
@Tag(name = "第三方登录", description = "微信、QQ等第三方登录接口")
public class OAuthController {
private final OAuthService oauthService;
@Operation(summary = "获取第三方登录授权URL")
@GetMapping("/auth-url/{platform}")
public Result<String> getAuthUrl(
@Parameter(description = "平台类型", example = "wechat")
@PathVariable String platform) {
log.info("获取第三方登录授权URL: {}", platform);
String authUrl = oauthService.getAuthUrl(platform);
return Result.success(authUrl);
}
@Operation(summary = "第三方登录")
@PostMapping("/login")
public Result<LoginResponse> oauthLogin(@Valid @RequestBody OAuthLoginRequest request) {
log.info("第三方登录请求: {}", request.getPlatform());
LoginResponse response = oauthService.oauthLogin(request);
return Result.success(response);
}
@Operation(summary = "获取第三方用户信息")
@GetMapping("/user-info/{platform}")
public Result<Object> getOAuthUserInfo(
@Parameter(description = "平台类型") @PathVariable String platform,
@Parameter(description = "授权码") @RequestParam String code,
@Parameter(description = "状态码") @RequestParam(required = false) String state) {
log.info("获取第三方用户信息: {}", platform);
Object userInfo = oauthService.getOAuthUserInfo(platform, code, state);
return Result.success(userInfo);
}
}
@@ -1,31 +0,0 @@
package com.emotionmuseum.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 验证码响应
*
* @author huazhongmin
* @since 2025-07-15
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "验证码响应")
public class CaptchaResponse {
@Schema(description = "验证码ID")
private String captchaId;
@Schema(description = "验证码图片Base64")
private String captchaImage;
@Schema(description = "验证码类型", example = "arithmetic")
private String captchaType;
@Schema(description = "过期时间(秒)", example = "300")
private Long expireTime;
}
@@ -1,36 +0,0 @@
package com.emotionmuseum.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 用户登录请求
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "用户登录请求")
public class LoginRequest {
@Schema(description = "账号(支持账号/邮箱/手机号)", example = "test_user")
@NotBlank(message = "账号不能为空")
private String account;
@Schema(description = "密码", example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "验证码ID", example = "captcha_123")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "验证码", example = "1234")
@NotBlank(message = "验证码不能为空")
private String captcha;
@Schema(description = "记住我", example = "false")
private Boolean rememberMe = false;
}
@@ -1,36 +0,0 @@
package com.emotionmuseum.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 第三方登录请求
*
* @author huazhongmin
* @since 2025-07-15
*/
@Data
@Schema(description = "第三方登录请求")
public class OAuthLoginRequest {
@Schema(description = "第三方平台类型", example = "wechat")
@NotBlank(message = "平台类型不能为空")
private String platform;
@Schema(description = "授权码", example = "auth_code_123")
@NotBlank(message = "授权码不能为空")
private String code;
@Schema(description = "状态码", example = "state_123")
private String state;
@Schema(description = "验证码ID", example = "captcha_123")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "验证码", example = "1234")
@NotBlank(message = "验证码不能为空")
private String captcha;
}
@@ -1,78 +0,0 @@
package com.emotionmuseum.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
/**
* 用户注册请求
*
* @author huazhongmin
* @since 2025-07-12
*/
@Data
@Schema(description = "用户注册请求")
public class RegisterRequest {
@Schema(description = "账号", example = "test_user")
@NotBlank(message = "账号不能为空")
@Size(min = 4, max = 20, message = "账号长度必须在4-20位之间")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "账号只能包含字母、数字和下划线")
private String account;
@Schema(description = "密码", example = "123456")
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String password;
@Schema(description = "确认密码", example = "123456")
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
@Schema(description = "验证码ID", example = "captcha_123")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "验证码", example = "1234")
@NotBlank(message = "验证码不能为空")
private String captcha;
@Schema(description = "用户名", example = "测试用户")
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 20, message = "用户名长度必须在2-20位之间")
private String username;
@Schema(description = "邮箱", example = "test@example.com")
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@Schema(description = "手机号", example = "13800138000")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "昵称", example = "小测试")
@NotBlank(message = "昵称不能为空")
@Size(min = 1, max = 20, message = "昵称长度必须在1-20位之间")
private String nickname;
@Schema(description = "生日", example = "1990-01-01")
private LocalDate birthDate;
@Schema(description = "所在地", example = "北京市")
@Size(max = 50, message = "所在地长度不能超过50位")
private String location;
@Schema(description = "个人简介", example = "这是一个测试用户")
@Size(max = 200, message = "个人简介长度不能超过200位")
private String bio;
/**
* 验证密码一致性
*/
public boolean isPasswordMatch() {
return password != null && password.equals(confirmPassword);
}
}
@@ -1,37 +0,0 @@
package com.emotionmuseum.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 滑块验证码响应
*
* @author huazhongmin
* @since 2025-07-15
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "滑块验证码响应")
public class SliderCaptchaResponse {
@Schema(description = "验证码ID")
private String captchaId;
@Schema(description = "背景图片Base64")
private String backgroundImage;
@Schema(description = "滑块图片Base64")
private String sliderImage;
@Schema(description = "滑块X坐标")
private Integer sliderX;
@Schema(description = "滑块Y坐标")
private Integer sliderY;
@Schema(description = "过期时间(秒)")
private Long expireTime;
}
@@ -1,33 +0,0 @@
package com.emotionmuseum.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 滑块验证码验证请求
*
* @author huazhongmin
* @since 2025-07-15
*/
@Data
@Schema(description = "滑块验证码验证请求")
public class SliderCaptchaVerifyRequest {
@Schema(description = "验证码ID")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "滑块X坐标")
@NotNull(message = "滑块X坐标不能为空")
private Integer x;
@Schema(description = "滑块Y坐标")
@NotNull(message = "滑块Y坐标不能为空")
private Integer y;
@Schema(description = "滑动轨迹")
private String track;
}
@@ -1,160 +0,0 @@
package com.emotionmuseum.auth.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotionmuseum.common.entity.BaseEntity;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 用户实体
*
* @author huazhongmin
* @since 2025-07-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("user")
public class User extends BaseEntity {
/**
* 账号
*/
@TableField("account")
private String account;
/**
* 密码
*/
@TableField("password")
@JsonIgnore
private String password;
/**
* 用户名
*/
@TableField("username")
private String username;
/**
* 邮箱
*/
@TableField("email")
private String email;
/**
* 手机号
*/
@TableField("phone")
private String phone;
/**
* 头像URL
*/
@TableField("avatar")
private String avatar;
/**
* 昵称
*/
@TableField("nickname")
private String nickname;
/**
* 生日
*/
@TableField("birth_date")
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate birthDate;
/**
* 所在地
*/
@TableField("location")
private String location;
/**
* 个人简介
*/
@TableField("bio")
private String bio;
/**
* 会员等级
*/
@TableField("member_level")
private String memberLevel;
/**
* 使用天数
*/
@TableField("total_days")
private Integer totalDays;
/**
* 自我感知
*/
@TableField("self_awareness")
private BigDecimal selfAwareness;
/**
* 情绪韧性
*/
@TableField("emotional_resilience")
private BigDecimal emotionalResilience;
/**
* 行动力
*/
@TableField("action_power")
private BigDecimal actionPower;
/**
* 共情力
*/
@TableField("empathy")
private BigDecimal empathy;
/**
* 生活热度
*/
@TableField("life_enthusiasm")
private BigDecimal lifeEnthusiasm;
/**
* 状态:0-禁用,1-正常
*/
@TableField("status")
private Integer status;
/**
* 是否已验证:0-未验证,1-已验证
*/
@TableField("is_verified")
private Integer isVerified;
/**
* 最后活跃时间
*/
@TableField("last_active_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastActiveTime;
/**
* 第三方登录平台
*/
@TableField("oauth_platform")
private String oauthPlatform;
/**
* 第三方登录ID
*/
@TableField("oauth_id")
private String oauthId;
}
@@ -1,56 +0,0 @@
package com.emotionmuseum.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotionmuseum.auth.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 用户Mapper接口
*
* @author huazhongmin
* @since 2025-07-16
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
/**
* 根据账号查询用户
*
* @param account 账号
* @return 用户信息
*/
User selectByAccount(@Param("account") String account);
/**
* 根据邮箱查询用户
*
* @param email 邮箱
* @return 用户信息
*/
User selectByEmail(@Param("email") String email);
/**
* 根据手机号查询用户
*
* @param phone 手机号
* @return 用户信息
*/
User selectByPhone(@Param("phone") String phone);
/**
* 根据第三方登录信息查询用户
*
* @param platform 平台
* @param oauthId 第三方ID
* @return 用户信息
*/
User selectByOAuth(@Param("platform") String platform, @Param("oauthId") String oauthId);
/**
* 更新最后活跃时间
*
* @param userId 用户ID
*/
void updateLastActiveTime(@Param("userId") String userId);
}
@@ -1,41 +0,0 @@
package com.emotionmuseum.auth.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
/**
* 用户登录请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "用户登录请求")
public class LoginRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "账号(支持账号/邮箱/手机号)", example = "test_user")
@NotBlank(message = "账号不能为空")
private String account;
@Schema(description = "密码", example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "验证码ID", example = "captcha_123")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "验证码", example = "1234")
@NotBlank(message = "验证码不能为空")
private String captcha;
@Schema(description = "记住我", example = "false")
private Boolean rememberMe = false;
}
@@ -1,41 +0,0 @@
package com.emotionmuseum.auth.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
/**
* 第三方登录请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "第三方登录请求")
public class OAuthLoginRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "第三方平台类型", example = "wechat")
@NotBlank(message = "平台类型不能为空")
private String platform;
@Schema(description = "授权码", example = "auth_code_123")
@NotBlank(message = "授权码不能为空")
private String code;
@Schema(description = "状态码", example = "state_123")
private String state;
@Schema(description = "验证码ID", example = "captcha_123")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "验证码", example = "1234")
@NotBlank(message = "验证码不能为空")
private String captcha;
}
@@ -1,83 +0,0 @@
package com.emotionmuseum.auth.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
/**
* 用户注册请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "用户注册请求")
public class RegisterRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "账号", example = "test_user")
@NotBlank(message = "账号不能为空")
@Size(min = 4, max = 20, message = "账号长度必须在4-20位之间")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "账号只能包含字母、数字和下划线")
private String account;
@Schema(description = "密码", example = "123456")
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String password;
@Schema(description = "确认密码", example = "123456")
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
@Schema(description = "验证码ID", example = "captcha_123")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "验证码", example = "1234")
@NotBlank(message = "验证码不能为空")
private String captcha;
@Schema(description = "用户名", example = "测试用户")
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 20, message = "用户名长度必须在2-20位之间")
private String username;
@Schema(description = "邮箱", example = "test@example.com")
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@Schema(description = "手机号", example = "13800138000")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "昵称", example = "小测试")
@NotBlank(message = "昵称不能为空")
@Size(min = 1, max = 20, message = "昵称长度必须在1-20位之间")
private String nickname;
@Schema(description = "生日", example = "1990-01-01")
private LocalDate birthDate;
@Schema(description = "所在地", example = "北京市")
@Size(max = 50, message = "所在地长度不能超过50位")
private String location;
@Schema(description = "个人简介", example = "这是一个测试用户")
@Size(max = 200, message = "个人简介长度不能超过200位")
private String bio;
/**
* 验证密码一致性
*/
public boolean isPasswordMatch() {
return password != null && password.equals(confirmPassword);
}
}
@@ -1,49 +0,0 @@
package com.emotionmuseum.auth.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 重置密码请求
*
* <p>用于未登录情况下通过手机号与验证码(本期固定为 123456)设置新密码。</p>
*
* @author huazhongmin
* @since 2025-10-26
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "重置密码请求")
public class ResetPasswordRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
/**
* 手机号
*/
@Schema(description = "手机号", example = "13800138000")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 新密码
*/
@Schema(description = "新密码", example = "NewPass_123")
@NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String newPassword;
/**
* 验证码(本期为固定值 123456)
*/
@Schema(description = "验证码(固定为123456", example = "123456")
@NotBlank(message = "验证码不能为空")
private String captcha;
}
@@ -1,38 +0,0 @@
package com.emotionmuseum.auth.request;
import com.emotionmuseum.common.request.BaseRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 滑块验证码验证请求
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "滑块验证码验证请求")
public class SliderCaptchaVerifyRequest extends BaseRequest {
private static final long serialVersionUID = 1L;
@Schema(description = "验证码ID")
@NotBlank(message = "验证码ID不能为空")
private String captchaId;
@Schema(description = "滑块X坐标")
@NotNull(message = "滑块X坐标不能为空")
private Integer x;
@Schema(description = "滑块Y坐标")
@NotNull(message = "滑块Y坐标不能为空")
private Integer y;
@Schema(description = "滑动轨迹")
private String track;
}
@@ -1,36 +0,0 @@
package com.emotionmuseum.auth.response;
import com.emotionmuseum.common.response.BaseResponse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 验证码响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Schema(description = "验证码响应")
public class CaptchaResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "验证码ID")
private String captchaId;
@Schema(description = "验证码图片Base64")
private String captchaImage;
@Schema(description = "验证码类型", example = "arithmetic")
private String captchaType;
@Schema(description = "过期时间(秒)", example = "300")
private Long expireTime;
}
@@ -1,42 +0,0 @@
package com.emotionmuseum.auth.response;
import com.emotionmuseum.common.response.BaseResponse;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 登录响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "登录响应")
public class LoginResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "访问Token")
private String accessToken;
@Schema(description = "刷新Token")
private String refreshToken;
@Schema(description = "Token类型", example = "Bearer")
private String tokenType = "Bearer";
@Schema(description = "Token过期时间(秒)", example = "86400")
private Long expiresIn;
@Schema(description = "用户信息")
private UserInfoResponse userInfo;
@Schema(description = "登录时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime loginTime;
}
@@ -1,26 +0,0 @@
package com.emotionmuseum.auth.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 重置密码响应
*
* <p>不返回敏感信息,仅提供结果标识与提示。</p>
*
* @author huazhongmin
* @since 2025-10-26
*/
@Data
@Schema(description = "重置密码响应")
public class ResetPasswordResponse {
/** 是否重置成功 */
@Schema(description = "是否重置成功", example = "true")
private boolean success;
/** 提示信息 */
@Schema(description = "提示信息", example = "重置密码成功")
private String message;
}
@@ -1,42 +0,0 @@
package com.emotionmuseum.auth.response;
import com.emotionmuseum.common.response.BaseResponse;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* 滑块验证码响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Schema(description = "滑块验证码响应")
public class SliderCaptchaResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "验证码ID")
private String captchaId;
@Schema(description = "背景图片Base64")
private String backgroundImage;
@Schema(description = "滑块图片Base64")
private String sliderImage;
@Schema(description = "滑块X坐标")
private Integer sliderX;
@Schema(description = "滑块Y坐标")
private Integer sliderY;
@Schema(description = "过期时间(秒)")
private Long expireTime;
}
@@ -1,102 +0,0 @@
package com.emotionmuseum.auth.response;
import com.emotionmuseum.common.response.BaseResponse;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 用户信息响应
*
* @author huazhongmin
* @since 2025-07-24
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "用户信息响应")
public class UserInfoResponse extends BaseResponse {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID")
private String id;
@Schema(description = "账号")
private String account;
@Schema(description = "用户名")
private String username;
@Schema(description = "邮箱")
private String email;
@Schema(description = "手机号")
private String phone;
@Schema(description = "头像URL")
private String avatar;
@Schema(description = "昵称")
private String nickname;
@Schema(description = "生日")
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate birthDate;
@Schema(description = "所在地")
private String location;
@Schema(description = "个人简介")
private String bio;
@Schema(description = "会员等级")
private String memberLevel;
@Schema(description = "使用天数")
private Integer totalDays;
@Schema(description = "成长数据")
private GrowthStatsVO growthStats;
@Schema(description = "状态")
private Integer status;
@Schema(description = "是否已验证")
private Integer isVerified;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "最后活跃时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastActiveTime;
/**
* 成长数据VO
*/
@Data
@Schema(description = "成长数据")
public static class GrowthStatsVO {
@Schema(description = "自我感知")
private BigDecimal selfAwareness;
@Schema(description = "情绪韧性")
private BigDecimal emotionalResilience;
@Schema(description = "行动力")
private BigDecimal actionPower;
@Schema(description = "共情力")
private BigDecimal empathy;
@Schema(description = "生活热度")
private BigDecimal lifeEnthusiasm;
}
}
@@ -1,120 +0,0 @@
package com.emotionmuseum.auth.security;
import cn.hutool.core.util.StrUtil;
import com.emotionmuseum.common.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* JWT认证过滤器
*
* @author huazhongmin
* @since 2025-07-15
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
private final RedisTemplate<String, Object> redisTemplate;
private static final String TOKEN_PREFIX = "Bearer ";
private static final String HEADER_NAME = "Authorization";
private static final String REDIS_TOKEN_KEY_PREFIX = "auth:token:";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = extractTokenFromRequest(request);
if (StrUtil.isNotBlank(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
// 验证token有效性
if (jwtUtil.validateToken(token)) {
String userId = jwtUtil.getUserIdFromToken(token);
// 检查Redis中是否存在该token(用于登出功能)
String redisKey = REDIS_TOKEN_KEY_PREFIX + userId;
String redisToken = (String) redisTemplate.opsForValue().get(redisKey);
if (StrUtil.isNotBlank(redisToken) && redisToken.equals(token)) {
// 加载用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
// 创建认证对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 设置到安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
// 更新token在Redis中的过期时间
redisTemplate.expire(redisKey, 24, TimeUnit.HOURS);
log.debug("JWT认证成功,用户ID: {}", userId);
} else {
log.debug("Redis中未找到有效token,用户ID: {}", userId);
}
} else {
log.debug("JWT token无效");
}
}
} catch (Exception e) {
log.error("JWT认证过程中发生错误: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
/**
* 从请求中提取token
*/
private String extractTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(HEADER_NAME);
if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
/**
* 判断是否跳过JWT认证
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
// 跳过认证的路径
return path.startsWith("/user/register") ||
path.startsWith("/user/login") ||
path.startsWith("/user/refresh") ||
path.startsWith("/user/check/") ||
path.startsWith("/captcha/") ||
path.startsWith("/oauth/") ||
path.startsWith("/actuator/") ||
path.startsWith("/swagger-ui/") ||
path.startsWith("/v3/api-docs") ||
path.startsWith("/doc.html") ||
path.equals("/error");
}
}
@@ -1,117 +0,0 @@
package com.emotionmuseum.auth.security;
import com.emotionmuseum.auth.entity.User;
import com.emotionmuseum.auth.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Collections;
/**
* Spring Security用户详情服务实现
*
* @author huazhongmin
* @since 2025-07-15
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
log.debug("加载用户详情,用户ID: {}", userId);
User user = userMapper.selectById(userId);
if (user == null) {
log.warn("用户不存在,用户ID: {}", userId);
throw new UsernameNotFoundException("用户不存在: " + userId);
}
if (user.getStatus() == 0) {
log.warn("用户已被禁用,用户ID: {}", userId);
throw new UsernameNotFoundException("用户已被禁用: " + userId);
}
return new SecurityUser(user);
}
/**
* Spring Security用户详情实现类
*/
public static class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 这里可以根据用户角色返回权限
// 目前简单返回一个默认角色
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getId();
}
/**
* 获取用户账号
*/
public String getAccount() {
return user.getAccount();
}
/**
* 获取用户昵称
*/
public String getNickname() {
return user.getNickname();
}
/**
* 获取用户实体
*/
public User getUser() {
return user;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return user.getStatus() == 1;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return user.getStatus() == 1;
}
}
}
@@ -1,110 +0,0 @@
package com.emotionmuseum.auth.service;
import com.emotionmuseum.auth.request.LoginRequest;
import com.emotionmuseum.auth.request.RegisterRequest;
import com.emotionmuseum.auth.request.ResetPasswordRequest;
import com.emotionmuseum.auth.response.LoginResponse;
import com.emotionmuseum.auth.response.ResetPasswordResponse;
import com.emotionmuseum.auth.response.UserInfoResponse;
/**
* 认证服务接口
*
* <p>
* 注意:所有新增接口需遵循项目接口规范与异常处理规范。
* </p>
*
* @author huazhongmin
* @since 2025-07-16
*/
public interface AuthService {
/**
* 用户注册
*
* @param request 注册请求
* @return 用户信息响应
*/
UserInfoResponse register(RegisterRequest request);
/**
* 用户登录
*
* @param request 登录请求
* @return 登录响应
*/
LoginResponse login(LoginRequest request);
/**
* 刷新Token
*
* @param refreshToken 刷新Token
* @return 登录响应
*/
LoginResponse refreshToken(String refreshToken);
/**
* 用户登出
*
* @param userId 用户ID
*/
void logout(String userId);
/**
* 获取当前用户信息
*
* @return 用户信息响应
*/
UserInfoResponse getCurrentUserInfo();
/**
* 检查账号是否存在
*
* @param account 账号
* @return 是否存在
*/
boolean existsByAccount(String account);
/**
* 检查邮箱是否存在
*
* @param email 邮箱
* @return 是否存在
*/
boolean existsByEmail(String email);
/**
* 检查手机号是否存在
*
* @param phone 手机号
* @return 是否存在
*/
boolean existsByPhone(String phone);
/**
* 根据用户ID获取用户信息
*
* @param userId 用户ID
* @return 用户信息响应
*/
UserInfoResponse getUserInfo(String userId);
/**
* 更新最后活跃时间
*
* @param userId 用户ID
*/
void updateLastActiveTime(String userId);
/**
* 重置密码(未登录场景)
*
* <p>
* 通过手机号与验证码(当前固定为123456)设置新密码。
* </p>
*
* @param request 重置密码请求
* @return 重置密码响应
*/
ResetPasswordResponse resetPassword(ResetPasswordRequest request);
}
@@ -1,36 +0,0 @@
package com.emotionmuseum.auth.service;
import com.emotionmuseum.auth.response.CaptchaResponse;
/**
* 验证码服务接口
*
* @author huazhongmin
* @since 2025-07-15
*/
public interface CaptchaService {
/**
* 生成验证码
*
* @param type 验证码类型 (arithmetic, chinese, gif, spec)
* @return 验证码响应
*/
CaptchaResponse generateCaptcha(String type);
/**
* 验证验证码
*
* @param captchaId 验证码ID
* @param captcha 用户输入的验证码
* @return 是否验证成功
*/
boolean verifyCaptcha(String captchaId, String captcha);
/**
* 删除验证码
*
* @param captchaId 验证码ID
*/
void removeCaptcha(String captchaId);
}
@@ -1,39 +0,0 @@
package com.emotionmuseum.auth.service;
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
import com.emotionmuseum.auth.vo.LoginResponse;
/**
* 第三方登录服务接口
*
* @author huazhongmin
* @since 2025-07-15
*/
public interface OAuthService {
/**
* 获取第三方登录授权URL
*
* @param platform 平台类型 (wechat, qq, wechat-mp)
* @return 授权URL
*/
String getAuthUrl(String platform);
/**
* 第三方登录
*
* @param request 第三方登录请求
* @return 登录响应
*/
LoginResponse oauthLogin(OAuthLoginRequest request);
/**
* 获取第三方用户信息
*
* @param platform 平台类型
* @param code 授权码
* @param state 状态码
* @return 用户信息
*/
Object getOAuthUserInfo(String platform, String code, String state);
}
@@ -1,35 +0,0 @@
package com.emotionmuseum.auth.service;
import com.emotionmuseum.auth.response.SliderCaptchaResponse;
import com.emotionmuseum.auth.request.SliderCaptchaVerifyRequest;
/**
* 滑块验证码服务接口
*
* @author huazhongmin
* @since 2025-07-15
*/
public interface SliderCaptchaService {
/**
* 生成滑块验证码
*
* @return 滑块验证码响应
*/
SliderCaptchaResponse generateSliderCaptcha();
/**
* 验证滑块验证码
*
* @param request 验证请求
* @return 是否验证成功
*/
boolean verifySliderCaptcha(SliderCaptchaVerifyRequest request);
/**
* 删除滑块验证码
*
* @param captchaId 验证码ID
*/
void removeSliderCaptcha(String captchaId);
}
@@ -1,347 +0,0 @@
package com.emotionmuseum.auth.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotionmuseum.common.result.ResultCode;
import com.emotionmuseum.common.util.JwtUtil;
import com.emotionmuseum.auth.request.LoginRequest;
import com.emotionmuseum.auth.request.RegisterRequest;
import com.emotionmuseum.auth.request.ResetPasswordRequest;
import com.emotionmuseum.auth.entity.User;
import com.emotionmuseum.auth.mapper.UserMapper;
import com.emotionmuseum.auth.service.AuthService;
import com.emotionmuseum.auth.service.CaptchaService;
import com.emotionmuseum.auth.response.LoginResponse;
import com.emotionmuseum.auth.response.ResetPasswordResponse;
import com.emotionmuseum.auth.response.UserInfoResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* 认证服务实现
*
* @author huazhongmin
* @since 2025-07-16
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthServiceImpl extends ServiceImpl<UserMapper, User> implements AuthService {
private final ApplicationContext applicationContext;
private final CaptchaService captchaService;
private final JwtUtil jwtUtil;
private final RedisTemplate<String, Object> redisTemplate;
private static final String REDIS_TOKEN_KEY_PREFIX = "auth:token:";
@Override
@Transactional(rollbackFor = Exception.class)
public UserInfoResponse register(RegisterRequest request) {
// 验证验证码
if (!captchaService.verifyCaptcha(request.getCaptchaId(), request.getCaptcha())) {
throw new RuntimeException(ResultCode.CAPTCHA_ERROR.getMessage());
}
// 验证密码一致性
if (!request.isPasswordMatch()) {
throw new RuntimeException(ResultCode.PARAM_VALIDATION_ERROR.getMessage() + ": 两次密码不一致");
}
// 检查账号是否存在
if (existsByAccount(request.getAccount())) {
throw new RuntimeException(ResultCode.ACCOUNT_ALREADY_EXISTS.getMessage());
}
// 检查邮箱是否存在
if (StrUtil.isNotBlank(request.getEmail()) && existsByEmail(request.getEmail())) {
throw new RuntimeException(ResultCode.EMAIL_ALREADY_EXISTS.getMessage());
}
// 检查手机号是否存在
if (StrUtil.isNotBlank(request.getPhone()) && existsByPhone(request.getPhone())) {
throw new RuntimeException("手机号已存在");
}
// 创建用户
User user = new User();
BeanUtils.copyProperties(request, user);
// 加密密码
PasswordEncoder passwordEncoder = applicationContext.getBean(PasswordEncoder.class);
user.setPassword(passwordEncoder.encode(request.getPassword()));
// 设置默认值
user.setMemberLevel("free");
user.setTotalDays(0);
user.setSelfAwareness(new BigDecimal("50.00"));
user.setEmotionalResilience(new BigDecimal("50.00"));
user.setActionPower(new BigDecimal("50.00"));
user.setEmpathy(new BigDecimal("50.00"));
user.setLifeEnthusiasm(new BigDecimal("50.00"));
user.setStatus(1);
user.setIsVerified(0);
user.setLastActiveTime(LocalDateTime.now());
// 保存用户
save(user);
log.info("用户注册成功: {}", user.getAccount());
return convertToUserInfoResponse(user);
}
@Override
public LoginResponse login(LoginRequest request) {
// 验证验证码
if (!captchaService.verifyCaptcha(request.getCaptchaId(), request.getCaptcha())) {
throw new RuntimeException(ResultCode.CAPTCHA_ERROR.getMessage());
}
// 查找用户(支持账号/邮箱/手机号登录)
User user = findUserByAccount(request.getAccount());
if (user == null) {
throw new RuntimeException(ResultCode.USER_NOT_FOUND.getMessage());
}
// 验证密码
PasswordEncoder passwordEncoder = applicationContext.getBean(PasswordEncoder.class);
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new RuntimeException(ResultCode.INVALID_CREDENTIALS.getMessage());
}
// 检查用户状态
if (user.getStatus() == 0) {
throw new RuntimeException(ResultCode.USER_DISABLED.getMessage());
}
// 生成Token
String accessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername());
// 将token存储到Redis中(用于登出和token管理)
String redisKey = REDIS_TOKEN_KEY_PREFIX + user.getId();
redisTemplate.opsForValue().set(redisKey, accessToken, 24, TimeUnit.HOURS);
// 更新最后活跃时间
updateLastActiveTime(user.getId());
// 构建响应
LoginResponse response = new LoginResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setExpiresIn(86400L); // 24小时
response.setUserInfo(convertToUserInfoResponse(user));
response.setLoginTime(LocalDateTime.now());
log.info("用户登录成功: {}", user.getAccount());
return response;
}
@Override
public LoginResponse refreshToken(String refreshToken) {
try {
// 验证刷新Token
if (!jwtUtil.validateToken(refreshToken)) {
throw new RuntimeException("刷新Token无效");
}
// 从刷新Token中获取用户信息
String userId = jwtUtil.getUserIdFromToken(refreshToken);
User user = getById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 生成新的Token
String newAccessToken = jwtUtil.generateToken(user.getId(), user.getUsername());
String newRefreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getUsername());
// 更新Redis中的token
String redisKey = REDIS_TOKEN_KEY_PREFIX + user.getId();
redisTemplate.opsForValue().set(redisKey, newAccessToken, 24, TimeUnit.HOURS);
// 构建响应
LoginResponse response = new LoginResponse();
response.setAccessToken(newAccessToken);
response.setRefreshToken(newRefreshToken);
response.setExpiresIn(86400L);
response.setUserInfo(convertToUserInfoResponse(user));
response.setLoginTime(LocalDateTime.now());
log.info("Token刷新成功: {}", user.getAccount());
return response;
} catch (Exception e) {
log.error("Token刷新失败: {}", e.getMessage());
throw new RuntimeException("Token刷新失败");
}
}
@Override
public void logout(String userId) {
try {
// 从Redis中删除token
String redisKey = REDIS_TOKEN_KEY_PREFIX + userId;
redisTemplate.delete(redisKey);
log.info("用户登出成功: {}", userId);
} catch (Exception e) {
log.error("用户登出失败: {}", e.getMessage());
throw new RuntimeException("登出失败");
}
}
@Override
public UserInfoResponse getCurrentUserInfo() {
// 从安全上下文获取当前用户ID
String userId = getCurrentUserId();
if (StrUtil.isBlank(userId)) {
throw new RuntimeException("未登录");
}
User user = getById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
return convertToUserInfoResponse(user);
}
@Override
public boolean existsByAccount(String account) {
return baseMapper.selectByAccount(account) != null;
}
@Override
public boolean existsByEmail(String email) {
return baseMapper.selectByEmail(email) != null;
}
@Override
public boolean existsByPhone(String phone) {
return baseMapper.selectByPhone(phone) != null;
}
@Override
public UserInfoResponse getUserInfo(String userId) {
User user = getById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
return convertToUserInfoResponse(user);
}
/**
* 重置密码(未登录场景)
*
* <p>
* 校验验证码(当前固定为123456),按手机号查询用户,使用 PasswordEncoder(BCrypt) 加密新密码并更新。
* </p>
*
* @param request 重置密码请求
* @return 重置密码响应
*/
@Override
public ResetPasswordResponse resetPassword(ResetPasswordRequest request) {
// 校验验证码(本期约定固定为 123456)
if (!"123456".equals(request.getCaptcha())) {
throw new RuntimeException(ResultCode.CAPTCHA_ERROR.getMessage());
}
// 按手机号查找用户
User user = baseMapper.selectByPhone(request.getPhone());
if (user == null) {
throw new RuntimeException(ResultCode.USER_NOT_FOUND.getMessage());
}
// 获取加密器并加密新密码
PasswordEncoder passwordEncoder = applicationContext.getBean(PasswordEncoder.class);
String encoded = passwordEncoder.encode(request.getNewPassword());
// 仅更新密码与更新时间(遵循仅更新非空字段原则)
User toUpdate = new User();
toUpdate.setId(user.getId());
toUpdate.setPassword(encoded);
updateById(toUpdate);
log.info("用户重置密码成功: phone={}", request.getPhone());
ResetPasswordResponse resp = new ResetPasswordResponse();
resp.setSuccess(true);
resp.setMessage("重置密码成功");
return resp;
}
@Override
public void updateLastActiveTime(String userId) {
baseMapper.updateLastActiveTime(userId);
}
/**
* 根据账号查找用户(支持账号/邮箱/手机号)
*/
private User findUserByAccount(String account) {
// 先按账号查找
User user = baseMapper.selectByAccount(account);
if (user != null) {
return user;
}
// 按邮箱查找
if (account.contains("@")) {
user = baseMapper.selectByEmail(account);
if (user != null) {
return user;
}
}
// 按手机号查找
if (account.matches("^1[3-9]\\d{9}$")) {
user = baseMapper.selectByPhone(account);
if (user != null) {
return user;
}
}
return null;
}
/**
* 转换为用户信息响应
*/
private UserInfoResponse convertToUserInfoResponse(User user) {
UserInfoResponse response = new UserInfoResponse();
BeanUtils.copyProperties(user, response);
// 设置成长数据
UserInfoResponse.GrowthStatsVO growthStats = new UserInfoResponse.GrowthStatsVO();
growthStats.setSelfAwareness(user.getSelfAwareness());
growthStats.setEmotionalResilience(user.getEmotionalResilience());
growthStats.setActionPower(user.getActionPower());
growthStats.setEmpathy(user.getEmpathy());
growthStats.setLifeEnthusiasm(user.getLifeEnthusiasm());
response.setGrowthStats(growthStats);
return response;
}
/**
* 获取当前用户ID
*/
private String getCurrentUserId() {
try {
return SecurityContextHolder.getContext().getAuthentication().getName();
} catch (Exception e) {
return null;
}
}
}
@@ -1,119 +0,0 @@
package com.emotionmuseum.auth.service.impl;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.emotionmuseum.auth.response.CaptchaResponse;
import com.emotionmuseum.auth.response.SliderCaptchaResponse;
import com.emotionmuseum.auth.request.SliderCaptchaVerifyRequest;
import com.emotionmuseum.auth.service.CaptchaService;
import com.emotionmuseum.auth.service.SliderCaptchaService;
import com.wf.captcha.base.Captcha;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* 验证码服务实现
*
* @author huazhongmin
* @since 2025-07-15
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CaptchaServiceImpl implements CaptchaService {
private final RedisTemplate<String, Object> redisTemplate;
private final ApplicationContext applicationContext;
private static final String CAPTCHA_KEY_PREFIX = "captcha:";
private static final long CAPTCHA_EXPIRE_TIME = 300; // 5分钟
@Override
public CaptchaResponse generateCaptcha(String type) {
try {
// 根据类型获取验证码Bean
String beanName = getBeanNameByType(type);
Captcha captcha = (Captcha) applicationContext.getBean(beanName);
// 生成验证码
String captchaId = IdUtil.simpleUUID();
String captchaText = captcha.text();
String captchaImage = captcha.toBase64();
// 存储到Redis
String redisKey = CAPTCHA_KEY_PREFIX + captchaId;
redisTemplate.opsForValue().set(redisKey, captchaText.toLowerCase(), CAPTCHA_EXPIRE_TIME, TimeUnit.SECONDS);
log.debug("生成验证码成功,ID: {}, 内容: {}", captchaId, captchaText);
return new CaptchaResponse(captchaId, captchaImage, type, CAPTCHA_EXPIRE_TIME);
} catch (Exception e) {
log.error("生成验证码失败: {}", e.getMessage());
throw new RuntimeException("生成验证码失败");
}
}
@Override
public boolean verifyCaptcha(String captchaId, String captcha) {
if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(captcha)) {
return false;
}
try {
String redisKey = CAPTCHA_KEY_PREFIX + captchaId;
String storedCaptcha = (String) redisTemplate.opsForValue().get(redisKey);
if (StrUtil.isBlank(storedCaptcha)) {
log.warn("验证码已过期或不存在,ID: {}", captchaId);
return false;
}
// 验证码不区分大小写
boolean isValid = storedCaptcha.equalsIgnoreCase(captcha.trim());
if (isValid) {
// 验证成功后删除验证码
redisTemplate.delete(redisKey);
log.debug("验证码验证成功,ID: {}", captchaId);
} else {
log.warn("验证码验证失败,ID: {}, 期望: {}, 实际: {}", captchaId, storedCaptcha, captcha);
}
return isValid;
} catch (Exception e) {
log.error("验证验证码失败: {}", e.getMessage());
return false;
}
}
@Override
public void removeCaptcha(String captchaId) {
if (StrUtil.isNotBlank(captchaId)) {
String redisKey = CAPTCHA_KEY_PREFIX + captchaId;
redisTemplate.delete(redisKey);
log.debug("删除验证码,ID: {}", captchaId);
}
}
/**
* 根据类型获取Bean名称
*/
private String getBeanNameByType(String type) {
String defaultType = StrUtil.blankToDefault(type, "spec");
switch (defaultType) {
case "arithmetic":
return "arithmeticCaptcha";
case "chinese":
return "chineseCaptcha";
case "gif":
return "gifCaptcha";
default:
return "specCaptcha";
}
}
}

Some files were not shown because too many files have changed in this diff Show More