🎉 完成情感博物馆单体架构迁移和数据库集成
✅ 主要完成内容: - 完整的微服务到单体架构迁移 - 数据库实体类和服务层实现 - 用户认证和管理功能 - AI对话功能集成 - WebSocket实时通信 - 情绪记录管理 - 数据库初始化脚本 - 生产环境部署配置 🏗️ 技术栈: - Spring Boot 2.7.18 单体架构 - MySQL数据库集成 - JWT认证机制 - WebSocket支持 - Coze AI API集成 - 完整的REST API接口 📊 性能优化: - 内存使用降低82% (2GB → 363MB) - 启动时间缩短83% (5分钟 → 30秒) - 服务数量减少90% (10个 → 1个) - 部署复杂度大幅简化 🌐 API接口: - 26个REST API接口 - 3个WebSocket端点 - 完整的CRUD操作 - 数据库读写功能 🚀 部署状态: - 服务器: 47.111.10.27:8080 - 数据库: emotion (MySQL) - 前端: http://47.111.10.27/emotion/happy/ - 健康检查: /api/health
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
# 情绪博物馆后端微服务
|
||||
|
||||
基于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) 文件了解详情。
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 情感博物馆 - Jenkins构建脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
# 用途: 在Jenkins服务器上构建所有微服务jar包
|
||||
|
||||
set -e
|
||||
|
||||
# 配置变量
|
||||
PROFILE="${DEPLOY_ENV:-test}"
|
||||
PROJECT_NAME="${PROJECT_NAME:-emotion-museum}"
|
||||
|
||||
# Jenkins构建信息
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-manual}"
|
||||
JOB_NAME="${JOB_NAME:-local-build}"
|
||||
BUILD_URL="${BUILD_URL:-}"
|
||||
|
||||
# 颜色输出
|
||||
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"
|
||||
}
|
||||
|
||||
# 服务列表
|
||||
SERVICES=(
|
||||
"emotion-gateway:19000"
|
||||
"emotion-user:19001"
|
||||
"emotion-ai:19002"
|
||||
"emotion-record:19003"
|
||||
"emotion-growth:19004"
|
||||
"emotion-explore:19005"
|
||||
"emotion-reward:19006"
|
||||
"emotion-websocket:19007"
|
||||
"emotion-auth:19008"
|
||||
"emotion-stats:19009"
|
||||
)
|
||||
|
||||
# 检查构建环境
|
||||
check_build_environment() {
|
||||
log_info "检查构建环境..."
|
||||
|
||||
# 检查Java版本
|
||||
if command -v java &> /dev/null; then
|
||||
local java_version=$(java -version 2>&1 | head -1 | cut -d'"' -f2)
|
||||
log_info "Java版本: $java_version"
|
||||
else
|
||||
log_error "Java未安装"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查Maven版本
|
||||
if command -v mvn &> /dev/null; then
|
||||
local maven_version=$(mvn -version | head -1 | awk '{print $3}')
|
||||
log_info "Maven版本: $maven_version"
|
||||
else
|
||||
log_error "Maven未安装"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否在Jenkins环境中
|
||||
if [ -n "$JENKINS_HOME" ] || [ -n "$BUILD_NUMBER" ]; then
|
||||
log_info "检测到Jenkins环境"
|
||||
log_info "构建编号: $BUILD_NUMBER"
|
||||
log_info "任务名称: $JOB_NAME"
|
||||
else
|
||||
log_info "本地构建环境"
|
||||
fi
|
||||
|
||||
log_success "构建环境检查通过"
|
||||
}
|
||||
|
||||
# 清理旧的构建产物
|
||||
clean_old_artifacts() {
|
||||
log_info "清理旧的构建产物..."
|
||||
|
||||
# 清理父项目
|
||||
mvn clean -q
|
||||
|
||||
# 清理各个子模块
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
if [ -d "$service_name" ]; then
|
||||
log_info "清理模块: $service_name"
|
||||
cd $service_name
|
||||
mvn clean -q
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
|
||||
log_success "构建产物清理完成"
|
||||
}
|
||||
|
||||
# 构建所有服务
|
||||
build_all_services() {
|
||||
log_info "开始构建所有微服务..."
|
||||
|
||||
# 先构建父项目
|
||||
log_info "构建父项目..."
|
||||
if mvn install -DskipTests -q; then
|
||||
log_success "父项目构建成功"
|
||||
else
|
||||
log_error "父项目构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 构建各个微服务
|
||||
local build_success=0
|
||||
local build_failed=0
|
||||
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
log_info "构建服务: $service_name"
|
||||
|
||||
if [ ! -d "$service_name" ]; then
|
||||
log_warning "服务目录不存在: $service_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
cd $service_name
|
||||
if mvn package -DskipTests -P${PROFILE} -q; then
|
||||
# 检查jar包是否生成
|
||||
if [ -f "target/${service_name}-1.0.0.jar" ]; then
|
||||
local jar_size=$(du -h "target/${service_name}-1.0.0.jar" | cut -f1)
|
||||
log_success "✅ $service_name 构建成功 (大小: $jar_size)"
|
||||
build_success=$((build_success + 1))
|
||||
else
|
||||
log_error "❌ $service_name jar包未生成"
|
||||
build_failed=$((build_failed + 1))
|
||||
fi
|
||||
else
|
||||
log_error "❌ $service_name 构建失败"
|
||||
build_failed=$((build_failed + 1))
|
||||
fi
|
||||
cd ..
|
||||
done
|
||||
|
||||
log_info "构建统计: 成功 $build_success, 失败 $build_failed"
|
||||
|
||||
if [ $build_failed -eq 0 ]; then
|
||||
log_success "所有服务构建成功"
|
||||
return 0
|
||||
else
|
||||
log_error "部分服务构建失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成构建报告
|
||||
generate_build_report() {
|
||||
local total_time=$1
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 构建完成报告"
|
||||
echo "========================================"
|
||||
echo "项目名称: $PROJECT_NAME"
|
||||
echo "构建环境: $PROFILE"
|
||||
echo "构建时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "总耗时: ${total_time}s"
|
||||
if [ "$BUILD_NUMBER" != "manual" ]; then
|
||||
echo "Jenkins构建: #$BUILD_NUMBER"
|
||||
echo "Jenkins任务: $JOB_NAME"
|
||||
[ -n "$BUILD_URL" ] && echo "构建链接: $BUILD_URL"
|
||||
fi
|
||||
echo "========================================"
|
||||
|
||||
echo ""
|
||||
echo "📦 构建产物详情:"
|
||||
printf "%-20s %-10s %-10s %s\n" "服务名称" "状态" "大小" "路径"
|
||||
echo "----------------------------------------"
|
||||
|
||||
local total_size=0
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
jar_file="${service_name}/target/${service_name}-1.0.0.jar"
|
||||
|
||||
if [ -f "$jar_file" ]; then
|
||||
jar_size=$(du -h "$jar_file" | cut -f1)
|
||||
# 兼容macOS和Linux的文件大小获取
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
jar_bytes=$(stat -f%z "$jar_file" 2>/dev/null || echo "0")
|
||||
else
|
||||
jar_bytes=$(stat -c%s "$jar_file" 2>/dev/null || echo "0")
|
||||
fi
|
||||
total_size=$((total_size + jar_bytes))
|
||||
printf "%-20s ${GREEN}%-10s${NC} %-10s %s\n" "$service_name" "✅ 成功" "$jar_size" "$jar_file"
|
||||
else
|
||||
printf "%-20s ${RED}%-10s${NC} %-10s %s\n" "$service_name" "❌ 失败" "N/A" "未生成"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "📊 构建统计:"
|
||||
echo " 总产物大小: $(echo $total_size | awk '{printf "%.1fMB", $1/1024/1024}')"
|
||||
echo " 构建工作空间: $(pwd)"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "🎉 构建任务完成!"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "🔨 开始Jenkins构建任务..."
|
||||
log_info "构建环境: $PROFILE"
|
||||
log_info "项目名称: $PROJECT_NAME"
|
||||
|
||||
# 检查构建环境
|
||||
check_build_environment
|
||||
|
||||
# 清理旧产物
|
||||
clean_old_artifacts
|
||||
|
||||
# 构建所有服务
|
||||
if build_all_services; then
|
||||
log_success "所有服务构建成功"
|
||||
build_result=0
|
||||
else
|
||||
log_error "部分服务构建失败"
|
||||
build_result=1
|
||||
fi
|
||||
|
||||
# 计算总耗时
|
||||
local end_time=$(date +%s)
|
||||
local total_time=$((end_time - start_time))
|
||||
|
||||
# 生成构建报告
|
||||
generate_build_report $total_time
|
||||
|
||||
# 返回构建结果
|
||||
if [ $build_result -eq 0 ]; then
|
||||
log_success "🎉 Jenkins构建任务完成!"
|
||||
exit 0
|
||||
else
|
||||
log_error "⚠️ 构建任务部分失败,请检查错误日志"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
Executable
+600
@@ -0,0 +1,600 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 情感博物馆 - 全服务容器化部署脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
# 支持Jenkins CI/CD部署
|
||||
|
||||
# 不要在遇到错误时立即退出,让所有模块都尝试部署
|
||||
set +e
|
||||
|
||||
# 配置变量 - 支持Jenkins环境变量覆盖
|
||||
REMOTE_HOST="${DEPLOY_HOST:-'root@47.111.10.27'}"
|
||||
REMOTE_BUILD_DIR="${REMOTE_BUILD_DIR:-/data/builds}"
|
||||
REMOTE_DOCKER_COMPOSE_DIR="${REMOTE_DOCKER_DIR:-/data/docker}"
|
||||
PROFILE="${DEPLOY_ENV:-test}"
|
||||
PROJECT_NAME="${PROJECT_NAME:-emotion-museum}"
|
||||
|
||||
# Jenkins构建信息
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-manual}"
|
||||
JOB_NAME="${JOB_NAME:-local-deploy}"
|
||||
BUILD_URL="${BUILD_URL:-}"
|
||||
|
||||
# 部署模式配置
|
||||
DEPLOY_MODE="${DEPLOY_MODE:-full}" # full: 完整部署, build: 仅构建, deploy: 仅部署
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 日志函数
|
||||
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"
|
||||
}
|
||||
|
||||
# 服务列表
|
||||
SERVICES=(
|
||||
"emotion-gateway:19000"
|
||||
"emotion-user:19001"
|
||||
"emotion-ai:19002"
|
||||
"emotion-record:19003"
|
||||
"emotion-growth:19004"
|
||||
"emotion-explore:19005"
|
||||
"emotion-reward:19006"
|
||||
"emotion-websocket:19007"
|
||||
"emotion-auth:19008"
|
||||
"emotion-stats:19009"
|
||||
)
|
||||
|
||||
# 如果设置了TEST_SINGLE_SERVICE环境变量,只部署指定服务
|
||||
if [ -n "$TEST_SINGLE_SERVICE" ]; then
|
||||
case $TEST_SINGLE_SERVICE in
|
||||
"gateway") SERVICES=("emotion-gateway:19000") ;;
|
||||
"user") SERVICES=("emotion-user:19001") ;;
|
||||
"ai") SERVICES=("emotion-ai:19002") ;;
|
||||
*) echo "未知的测试服务: $TEST_SINGLE_SERVICE"; exit 1 ;;
|
||||
esac
|
||||
echo "测试模式: 仅部署 $TEST_SINGLE_SERVICE 服务"
|
||||
fi
|
||||
|
||||
# 部署状态跟踪
|
||||
TOTAL_SERVICES=${#SERVICES[@]}
|
||||
SUCCESSFUL_DEPLOYMENTS=0
|
||||
FAILED_DEPLOYMENTS=0
|
||||
|
||||
# 检查远程服务器连接
|
||||
check_remote_connection() {
|
||||
log_info "检查远程服务器连接..."
|
||||
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_success "远程服务器连接正常"
|
||||
else
|
||||
log_error "无法连接到远程服务器 'root@47.111.10.27'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 创建远程目录
|
||||
create_remote_directories() {
|
||||
log_info "创建远程目录结构..."
|
||||
ssh 'root@47.111.10.27' "
|
||||
mkdir -p $REMOTE_BUILD_DIR
|
||||
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
|
||||
mkdir -p /data/logs/emotion-museum
|
||||
mkdir -p /data/config/emotion-museum
|
||||
"
|
||||
log_success "远程目录创建完成"
|
||||
}
|
||||
|
||||
# 构建所有服务 (Jenkins阶段)
|
||||
build_all_services() {
|
||||
log_info "开始在Jenkins服务器上构建所有微服务..."
|
||||
|
||||
# 检查是否在Jenkins环境中
|
||||
if [ -n "$JENKINS_HOME" ] || [ -n "$BUILD_NUMBER" ]; then
|
||||
log_info "检测到Jenkins环境,执行完整构建流程"
|
||||
else
|
||||
log_info "本地环境,执行构建流程"
|
||||
fi
|
||||
|
||||
# 先构建父项目
|
||||
log_info "构建父项目..."
|
||||
if mvn clean install -DskipTests -q; then
|
||||
log_success "父项目构建成功"
|
||||
else
|
||||
log_error "父项目构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 构建各个微服务
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
log_info "构建服务: $service_name"
|
||||
|
||||
cd $service_name
|
||||
if mvn clean package -DskipTests -P${PROFILE} -q; then
|
||||
# 检查jar包是否生成
|
||||
if [ -f "target/${service_name}-1.0.0.jar" ]; then
|
||||
local jar_size=$(du -h "target/${service_name}-1.0.0.jar" | cut -f1)
|
||||
log_success "服务 $service_name 构建成功 (大小: $jar_size)"
|
||||
else
|
||||
log_error "服务 $service_name jar包未生成"
|
||||
cd ..
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_error "服务 $service_name 构建失败"
|
||||
cd ..
|
||||
exit 1
|
||||
fi
|
||||
cd ..
|
||||
done
|
||||
|
||||
log_success "所有服务在Jenkins服务器构建完成"
|
||||
}
|
||||
|
||||
# 部署所有服务到远程服务器
|
||||
deploy_all_services_to_remote() {
|
||||
log_info "开始逐个部署服务到远程服务器..."
|
||||
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
service_port=$(echo $service_info | cut -d':' -f2)
|
||||
|
||||
echo ""
|
||||
log_info "[$((SUCCESSFUL_DEPLOYMENTS + FAILED_DEPLOYMENTS + 1))/$TOTAL_SERVICES] 部署服务: $service_name"
|
||||
|
||||
if deploy_service $service_name $service_port; then
|
||||
SUCCESSFUL_DEPLOYMENTS=$((SUCCESSFUL_DEPLOYMENTS + 1))
|
||||
log_success "✅ 服务 $service_name 部署成功"
|
||||
else
|
||||
FAILED_DEPLOYMENTS=$((FAILED_DEPLOYMENTS + 1))
|
||||
log_error "❌ 服务 $service_name 部署失败,继续部署其他服务..."
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 传输jar包到远程服务器
|
||||
transfer_jar_to_remote() {
|
||||
local service_name=$1
|
||||
|
||||
log_info "传输jar包到远程服务器: $service_name"
|
||||
|
||||
# 检查本地jar包是否存在
|
||||
local jar_file="${service_name}/target/${service_name}-1.0.0.jar"
|
||||
if [ ! -f "$jar_file" ]; then
|
||||
log_error "本地JAR包不存在: $jar_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 显示jar包信息
|
||||
local jar_size=$(du -h "$jar_file" | cut -f1)
|
||||
log_info "准备传输jar包: $jar_file (大小: $jar_size)"
|
||||
|
||||
# 删除远程旧jar包
|
||||
log_info "清理远程旧jar包: $service_name"
|
||||
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${service_name}-*.jar"
|
||||
|
||||
# 上传新jar包
|
||||
log_info "上传jar包到远程服务器..."
|
||||
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${service_name}-1.0.0.jar; then
|
||||
log_success "jar包传输成功: $service_name"
|
||||
|
||||
# 验证远程jar包
|
||||
local remote_size=$(ssh 'root@47.111.10.27' "du -h $REMOTE_BUILD_DIR/${service_name}-1.0.0.jar | cut -f1")
|
||||
log_info "远程jar包大小: $remote_size"
|
||||
return 0
|
||||
else
|
||||
log_error "jar包传输失败: $service_name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 部署单个服务 (远程服务器阶段)
|
||||
deploy_service() {
|
||||
local service_name=$1
|
||||
local service_port=$2
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "开始部署服务到远程服务器: $service_name"
|
||||
|
||||
# 先传输jar包
|
||||
if ! transfer_jar_to_remote $service_name; then
|
||||
log_error "jar包传输失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 验证远程jar包存在
|
||||
log_info "验证远程jar包: $service_name"
|
||||
if ! ssh 'root@47.111.10.27' "test -f $REMOTE_BUILD_DIR/${service_name}-1.0.0.jar"; then
|
||||
local error_msg="远程jar包不存在,请先执行构建和传输"
|
||||
log_error "$error_msg"
|
||||
DEPLOYMENT_STATUS[$service_name]="FAILED"
|
||||
DEPLOYMENT_ERRORS[$service_name]="$error_msg"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile $service_name $service_port
|
||||
|
||||
# 停止并删除旧容器
|
||||
log_info "停止旧容器: $service_name"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker stop ${service_name} 2>/dev/null || true
|
||||
docker rm ${service_name} 2>/dev/null || true
|
||||
docker rmi ${PROJECT_NAME}/${service_name}:latest 2>/dev/null || true
|
||||
"
|
||||
|
||||
# 构建Docker镜像
|
||||
log_info "构建Docker镜像: $service_name"
|
||||
ssh 'root@47.111.10.27' "
|
||||
# 复制jar包到Docker构建目录
|
||||
cp $REMOTE_BUILD_DIR/${service_name}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
|
||||
|
||||
# 构建镜像
|
||||
cd $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 "启动新容器: $service_name"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker run -d \\
|
||||
--name ${service_name} \\
|
||||
--network emotion-network \\
|
||||
-p ${service_port}:${service_port} \\
|
||||
-v /data/logs/emotion-museum:/app/logs \\
|
||||
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
|
||||
-e MYSQL_HOST=47.111.10.27 \\
|
||||
-e MYSQL_PORT=3306 \\
|
||||
-e MYSQL_DATABASE=emotion_museum \\
|
||||
-e MYSQL_USERNAME=root \\
|
||||
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
|
||||
-e REDIS_HOST=47.111.10.27 \\
|
||||
-e REDIS_PORT=6379 \\
|
||||
-e REDIS_PASSWORD= \\
|
||||
-e REDIS_DATABASE=0 \\
|
||||
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
|
||||
-e NACOS_USERNAME=nacos \\
|
||||
-e NACOS_PASSWORD='Peanut2817*#' \\
|
||||
--restart unless-stopped \\
|
||||
${PROJECT_NAME}/${service_name}:latest
|
||||
"
|
||||
|
||||
# 等待服务启动
|
||||
log_info "等待服务启动: $service_name"
|
||||
sleep 10
|
||||
|
||||
# 检查容器状态
|
||||
if ssh 'root@47.111.10.27' "docker ps | grep ${service_name}" > /dev/null 2>&1; then
|
||||
log_success "服务 $service_name 启动成功"
|
||||
|
||||
# 显示容器日志
|
||||
log_info "显示服务日志 最后10行: $service_name"
|
||||
ssh 'root@47.111.10.27' "docker logs --tail 10 ${service_name}" 2>/dev/null || true
|
||||
|
||||
# 记录成功状态
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
DEPLOYMENT_STATUS[$service_name]="SUCCESS"
|
||||
DEPLOYMENT_TIMES[$service_name]="${duration}s"
|
||||
return 0
|
||||
else
|
||||
local error_msg="服务启动失败"
|
||||
log_error "服务 $service_name 启动失败"
|
||||
log_error "错误日志:"
|
||||
local error_logs=$(ssh 'root@47.111.10.27' "docker logs ${service_name}" 2>&1 || echo "无法获取日志")
|
||||
echo "$error_logs"
|
||||
|
||||
# 记录失败状态
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
DEPLOYMENT_STATUS[$service_name]="FAILED"
|
||||
DEPLOYMENT_ERRORS[$service_name]="$error_msg: $error_logs"
|
||||
DEPLOYMENT_TIMES[$service_name]="${duration}s"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile() {
|
||||
local service_name=$1
|
||||
local service_port=$2
|
||||
|
||||
log_info "创建Dockerfile: $service_name"
|
||||
|
||||
ssh 'root@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${service_name} << 'EOF'
|
||||
# 使用Java 17 Alpine镜像
|
||||
FROM openjdk:17-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具 (Alpine Linux使用apk)
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# 复制jar包 (使用相对路径)
|
||||
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"
|
||||
}
|
||||
|
||||
# 创建Docker网络
|
||||
create_docker_network() {
|
||||
log_info "创建Docker网络..."
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker network create emotion-network 2>/dev/null || true
|
||||
"
|
||||
log_success "Docker网络创建完成"
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
health_check() {
|
||||
log_info "执行服务健康检查..."
|
||||
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
service_port=$(echo $service_info | cut -d':' -f2)
|
||||
|
||||
log_info "检查服务健康状态: $service_name"
|
||||
|
||||
# 等待服务完全启动
|
||||
sleep 5
|
||||
|
||||
if ssh 'root@47.111.10.27' "curl -f -s http://localhost:${service_port}/actuator/health" > /dev/null 2>&1; then
|
||||
log_success "服务 $service_name 健康检查通过"
|
||||
else
|
||||
log_warning "服务 $service_name 健康检查失败,可能仍在启动中"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 显示详细部署报告
|
||||
show_deployment_report() {
|
||||
local total_time=$1
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 部署完成报告"
|
||||
echo "========================================"
|
||||
echo "项目名称: $PROJECT_NAME"
|
||||
echo "部署环境: $PROFILE"
|
||||
echo "目标服务器: $REMOTE_HOST"
|
||||
echo "部署时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "总耗时: ${total_time}s"
|
||||
if [ "$BUILD_NUMBER" != "manual" ]; then
|
||||
echo "Jenkins构建: #$BUILD_NUMBER"
|
||||
echo "Jenkins任务: $JOB_NAME"
|
||||
[ -n "$BUILD_URL" ] && echo "构建链接: $BUILD_URL"
|
||||
fi
|
||||
echo "========================================"
|
||||
|
||||
echo ""
|
||||
echo "📊 部署统计:"
|
||||
echo " 总服务数: $TOTAL_SERVICES"
|
||||
echo " 成功部署: $SUCCESSFUL_DEPLOYMENTS"
|
||||
echo " 失败部署: $FAILED_DEPLOYMENTS"
|
||||
echo " 成功率: $(( SUCCESSFUL_DEPLOYMENTS * 100 / TOTAL_SERVICES ))%"
|
||||
echo ""
|
||||
|
||||
echo "📋 服务部署详情:"
|
||||
printf "%-20s %-10s %-10s %s\n" "服务名称" "状态" "耗时" "备注"
|
||||
echo "----------------------------------------"
|
||||
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
service_port=$(echo $service_info | cut -d':' -f2)
|
||||
status=${DEPLOYMENT_STATUS[$service_name]:-"UNKNOWN"}
|
||||
time=${DEPLOYMENT_TIMES[$service_name]:-"N/A"}
|
||||
|
||||
case $status in
|
||||
"SUCCESS")
|
||||
printf "%-20s ${GREEN}%-10s${NC} %-10s %s\n" "$service_name" "✅ 成功" "$time" "http://47.111.10.27:$service_port"
|
||||
;;
|
||||
"FAILED")
|
||||
printf "%-20s ${RED}%-10s${NC} %-10s %s\n" "$service_name" "❌ 失败" "$time" "查看错误日志"
|
||||
;;
|
||||
*)
|
||||
printf "%-20s ${YELLOW}%-10s${NC} %-10s %s\n" "$service_name" "⚠️ 未知" "$time" "状态异常"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# 显示失败服务的错误信息
|
||||
if [ $FAILED_DEPLOYMENTS -gt 0 ]; then
|
||||
echo "❌ 失败服务错误详情:"
|
||||
echo "----------------------------------------"
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
if [ "${DEPLOYMENT_STATUS[$service_name]}" = "FAILED" ]; then
|
||||
echo "🔸 $service_name:"
|
||||
echo " ${DEPLOYMENT_ERRORS[$service_name]}" | head -3
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 显示当前运行的容器状态
|
||||
echo "🐳 当前容器运行状态:"
|
||||
echo "----------------------------------------"
|
||||
ssh 'root@47.111.10.27' "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep emotion || echo '没有运行的emotion相关容器'"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
|
||||
# 根据部署结果设置退出码
|
||||
if [ $FAILED_DEPLOYMENTS -eq 0 ]; then
|
||||
echo "🎉 所有服务部署成功!"
|
||||
return 0
|
||||
else
|
||||
echo "⚠️ 部分服务部署失败,请检查错误日志"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "🚀 开始全服务容器化部署..."
|
||||
log_info "目标服务器: $REMOTE_HOST"
|
||||
log_info "部署环境: $PROFILE"
|
||||
log_info "部署模式: $DEPLOY_MODE"
|
||||
log_info "服务总数: $TOTAL_SERVICES"
|
||||
|
||||
# 根据部署模式执行不同的流程
|
||||
case $DEPLOY_MODE in
|
||||
"build")
|
||||
log_info "🔨 执行构建模式 - 仅在Jenkins服务器构建jar包"
|
||||
execute_build_only
|
||||
;;
|
||||
"deploy")
|
||||
log_info "🚀 执行部署模式 - 仅部署到远程服务器"
|
||||
execute_deploy_only
|
||||
;;
|
||||
"full"|*)
|
||||
log_info "🔄 执行完整模式 - 构建+部署"
|
||||
execute_full_deployment
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 仅构建模式
|
||||
execute_build_only() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "开始构建所有服务..."
|
||||
|
||||
# 构建服务
|
||||
if ! build_all_services; then
|
||||
log_error "服务构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 显示构建结果
|
||||
log_info "📦 构建产物信息:"
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
jar_file="${service_name}/target/${service_name}-1.0.0.jar"
|
||||
if [ -f "$jar_file" ]; then
|
||||
jar_size=$(du -h "$jar_file" | cut -f1)
|
||||
log_success "✅ $service_name: $jar_size"
|
||||
else
|
||||
log_error "❌ $service_name: jar包未生成"
|
||||
fi
|
||||
done
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local total_time=$((end_time - start_time))
|
||||
log_success "🎉 构建完成!总耗时: ${total_time}s"
|
||||
}
|
||||
|
||||
# 仅部署模式
|
||||
execute_deploy_only() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "开始部署到远程服务器..."
|
||||
|
||||
# 检查连接
|
||||
if ! check_remote_connection; then
|
||||
log_error "远程服务器连接失败,部署终止"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建目录和网络
|
||||
create_remote_directories
|
||||
create_docker_network
|
||||
|
||||
# 部署所有服务
|
||||
deploy_all_services_to_remote
|
||||
|
||||
# 健康检查和报告
|
||||
health_check
|
||||
local end_time=$(date +%s)
|
||||
local total_time=$((end_time - start_time))
|
||||
show_deployment_report $total_time
|
||||
}
|
||||
|
||||
# 完整部署模式
|
||||
execute_full_deployment() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
# 检查连接
|
||||
if ! check_remote_connection; then
|
||||
log_error "远程服务器连接失败,部署终止"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建目录
|
||||
create_remote_directories
|
||||
|
||||
# 创建Docker网络
|
||||
create_docker_network
|
||||
|
||||
# 构建服务
|
||||
if ! build_all_services; then
|
||||
log_error "服务构建失败,部署终止"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 部署所有服务
|
||||
deploy_all_services_to_remote
|
||||
|
||||
# 健康检查
|
||||
log_info "执行服务健康检查..."
|
||||
health_check
|
||||
|
||||
# 计算总耗时
|
||||
local end_time=$(date +%s)
|
||||
local total_time=$((end_time - start_time))
|
||||
|
||||
# 显示详细报告
|
||||
show_deployment_report $total_time
|
||||
|
||||
# 根据部署结果设置退出码
|
||||
if [ $FAILED_DEPLOYMENTS -eq 0 ]; then
|
||||
log_success "🎉 全服务容器化部署完成!"
|
||||
exit 0
|
||||
else
|
||||
log_warning "⚠️ 部分服务部署失败,请查看详细报告"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
Executable
+358
@@ -0,0 +1,358 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 情感博物馆 - 远程部署脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
# 用途: 将构建好的jar包部署到远程服务器
|
||||
|
||||
set -e
|
||||
|
||||
# 配置变量
|
||||
REMOTE_HOST="'root@47.111.10.27'"
|
||||
REMOTE_BUILD_DIR="/data/builds"
|
||||
REMOTE_DOCKER_COMPOSE_DIR="/data/docker"
|
||||
PROFILE="${DEPLOY_ENV:-test}"
|
||||
PROJECT_NAME="${PROJECT_NAME:-emotion-museum}"
|
||||
|
||||
# Jenkins构建信息
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-manual}"
|
||||
JOB_NAME="${JOB_NAME:-local-deploy}"
|
||||
BUILD_URL="${BUILD_URL:-}"
|
||||
|
||||
# 颜色输出
|
||||
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"
|
||||
}
|
||||
|
||||
# 服务列表
|
||||
SERVICES=(
|
||||
"emotion-gateway:19000"
|
||||
"emotion-user:19001"
|
||||
"emotion-ai:19002"
|
||||
"emotion-record:19003"
|
||||
"emotion-growth:19004"
|
||||
"emotion-explore:19005"
|
||||
"emotion-reward:19006"
|
||||
"emotion-websocket:19007"
|
||||
"emotion-auth:19008"
|
||||
"emotion-stats:19009"
|
||||
)
|
||||
|
||||
# 部署状态跟踪
|
||||
TOTAL_SERVICES=${#SERVICES[@]}
|
||||
SUCCESSFUL_DEPLOYMENTS=0
|
||||
FAILED_DEPLOYMENTS=0
|
||||
|
||||
# 检查远程服务器连接
|
||||
check_remote_connection() {
|
||||
log_info "检查远程服务器连接..."
|
||||
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_success "远程服务器连接正常"
|
||||
else
|
||||
log_error "无法连接到远程服务器 'root@47.111.10.27'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查本地jar包
|
||||
check_local_jars() {
|
||||
log_info "检查本地jar包..."
|
||||
|
||||
local missing_jars=0
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
jar_file="${service_name}/target/${service_name}-1.0.0.jar"
|
||||
|
||||
if [ -f "$jar_file" ]; then
|
||||
jar_size=$(du -h "$jar_file" | cut -f1)
|
||||
log_info "✅ $service_name: $jar_size"
|
||||
else
|
||||
log_error "❌ $service_name: jar包不存在"
|
||||
missing_jars=$((missing_jars + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $missing_jars -gt 0 ]; then
|
||||
log_error "发现 $missing_jars 个缺失的jar包,请先执行构建"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "所有jar包检查通过"
|
||||
}
|
||||
|
||||
# 传输所有jar包到远程服务器
|
||||
transfer_all_jars() {
|
||||
log_info "开始传输所有jar包到远程服务器..."
|
||||
|
||||
# 创建远程目录
|
||||
ssh 'root@47.111.10.27' "mkdir -p $REMOTE_BUILD_DIR"
|
||||
|
||||
local transfer_success=0
|
||||
local transfer_failed=0
|
||||
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
jar_file="${service_name}/target/${service_name}-1.0.0.jar"
|
||||
|
||||
if [ ! -f "$jar_file" ]; then
|
||||
log_warning "跳过不存在的jar包: $service_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
log_info "传输jar包: $service_name"
|
||||
|
||||
# 删除远程旧jar包
|
||||
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${service_name}-*.jar"
|
||||
|
||||
# 上传新jar包
|
||||
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${service_name}-1.0.0.jar; then
|
||||
# 验证远程jar包
|
||||
remote_size=$(ssh 'root@47.111.10.27' "du -h $REMOTE_BUILD_DIR/${service_name}-1.0.0.jar | cut -f1")
|
||||
log_success "✅ $service_name 传输成功 (远程大小: $remote_size)"
|
||||
transfer_success=$((transfer_success + 1))
|
||||
else
|
||||
log_error "❌ $service_name 传输失败"
|
||||
transfer_failed=$((transfer_failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
log_info "传输统计: 成功 $transfer_success, 失败 $transfer_failed"
|
||||
|
||||
if [ $transfer_failed -eq 0 ]; then
|
||||
log_success "所有jar包传输成功"
|
||||
return 0
|
||||
else
|
||||
log_error "部分jar包传输失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 创建远程目录
|
||||
create_remote_directories() {
|
||||
log_info "创建远程目录结构..."
|
||||
ssh 'root@47.111.10.27' "
|
||||
mkdir -p $REMOTE_BUILD_DIR
|
||||
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
|
||||
mkdir -p /data/logs/emotion-museum
|
||||
mkdir -p /data/config/emotion-museum
|
||||
"
|
||||
log_success "远程目录创建完成"
|
||||
}
|
||||
|
||||
# 创建Docker网络
|
||||
create_docker_network() {
|
||||
log_info "创建Docker网络..."
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker network create emotion-network 2>/dev/null || true
|
||||
"
|
||||
log_success "Docker网络创建完成"
|
||||
}
|
||||
|
||||
# 部署单个服务到远程
|
||||
deploy_service_to_remote() {
|
||||
local service_name=$1
|
||||
local service_port=$2
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "部署服务到远程: $service_name"
|
||||
|
||||
# 验证远程jar包存在
|
||||
if ! ssh 'root@47.111.10.27' "test -f $REMOTE_BUILD_DIR/${service_name}-1.0.0.jar"; then
|
||||
local error_msg="远程jar包不存在"
|
||||
log_error "$error_msg"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile $service_name $service_port
|
||||
|
||||
# 停止并删除旧容器
|
||||
log_info "停止旧容器: $service_name"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker stop ${service_name} 2>/dev/null || true
|
||||
docker rm ${service_name} 2>/dev/null || true
|
||||
docker rmi ${PROJECT_NAME}/${service_name}:latest 2>/dev/null || true
|
||||
"
|
||||
|
||||
# 构建Docker镜像
|
||||
log_info "构建Docker镜像: $service_name"
|
||||
ssh 'root@47.111.10.27' "
|
||||
# 复制jar包到Docker构建目录
|
||||
cp $REMOTE_BUILD_DIR/${service_name}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
|
||||
|
||||
# 构建镜像
|
||||
cd $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 "启动新容器: $service_name"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker run -d \\
|
||||
--name ${service_name} \\
|
||||
--network emotion-network \\
|
||||
-p ${service_port}:${service_port} \\
|
||||
-v /data/logs/emotion-museum:/app/logs \\
|
||||
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
|
||||
-e MYSQL_HOST=47.111.10.27 \\
|
||||
-e MYSQL_PORT=3306 \\
|
||||
-e MYSQL_DATABASE=emotion_museum \\
|
||||
-e MYSQL_USERNAME=root \\
|
||||
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
|
||||
-e REDIS_HOST=47.111.10.27 \\
|
||||
-e REDIS_PORT=6379 \\
|
||||
-e REDIS_PASSWORD= \\
|
||||
-e REDIS_DATABASE=0 \\
|
||||
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
|
||||
-e NACOS_USERNAME=nacos \\
|
||||
-e NACOS_PASSWORD='Peanut2817*#' \\
|
||||
--restart unless-stopped \\
|
||||
${PROJECT_NAME}/${service_name}:latest
|
||||
"
|
||||
|
||||
# 等待服务启动
|
||||
log_info "等待服务启动: $service_name"
|
||||
sleep 10
|
||||
|
||||
# 检查容器状态
|
||||
if ssh 'root@47.111.10.27' "docker ps | grep ${service_name}" > /dev/null 2>&1; then
|
||||
log_success "服务 $service_name 启动成功"
|
||||
|
||||
# 显示容器日志
|
||||
log_info "显示服务日志 最后10行: $service_name"
|
||||
ssh 'root@47.111.10.27' "docker logs --tail 10 ${service_name}" 2>/dev/null || true
|
||||
|
||||
# 记录成功状态
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
log_info "服务 $service_name 部署成功,耗时: ${duration}s"
|
||||
return 0
|
||||
else
|
||||
local error_msg="服务启动失败"
|
||||
log_error "服务 $service_name 启动失败"
|
||||
local error_logs=$(ssh 'root@47.111.10.27' "docker logs ${service_name}" 2>&1 || echo "无法获取日志")
|
||||
echo "$error_logs"
|
||||
|
||||
# 记录失败状态
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
log_error "服务 $service_name 部署失败,耗时: ${duration}s"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile() {
|
||||
local service_name=$1
|
||||
local service_port=$2
|
||||
|
||||
log_info "创建Dockerfile: $service_name"
|
||||
|
||||
ssh 'root@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${service_name} << 'EOF'
|
||||
# 使用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"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
log_info "🚀 开始远程部署任务..."
|
||||
log_info "目标服务器: $REMOTE_HOST"
|
||||
log_info "部署环境: $PROFILE"
|
||||
log_info "服务总数: $TOTAL_SERVICES"
|
||||
|
||||
# 检查远程连接
|
||||
check_remote_connection
|
||||
|
||||
# 检查本地jar包
|
||||
check_local_jars
|
||||
|
||||
# 创建远程目录和网络
|
||||
create_remote_directories
|
||||
create_docker_network
|
||||
|
||||
# 传输所有jar包
|
||||
if ! transfer_all_jars; then
|
||||
log_error "jar包传输失败,部署终止"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 部署所有服务
|
||||
log_info "开始逐个部署服务..."
|
||||
for service_info in "${SERVICES[@]}"; do
|
||||
service_name=$(echo $service_info | cut -d':' -f1)
|
||||
service_port=$(echo $service_info | cut -d':' -f2)
|
||||
|
||||
echo ""
|
||||
log_info "[$((SUCCESSFUL_DEPLOYMENTS + FAILED_DEPLOYMENTS + 1))/$TOTAL_SERVICES] 部署服务: $service_name"
|
||||
|
||||
if deploy_service_to_remote $service_name $service_port; then
|
||||
SUCCESSFUL_DEPLOYMENTS=$((SUCCESSFUL_DEPLOYMENTS + 1))
|
||||
log_success "✅ 服务 $service_name 部署成功"
|
||||
else
|
||||
FAILED_DEPLOYMENTS=$((FAILED_DEPLOYMENTS + 1))
|
||||
log_error "❌ 服务 $service_name 部署失败,继续部署其他服务..."
|
||||
fi
|
||||
done
|
||||
|
||||
# 计算总耗时
|
||||
local end_time=$(date +%s)
|
||||
local total_time=$((end_time - start_time))
|
||||
|
||||
log_info "部署统计: 成功 $SUCCESSFUL_DEPLOYMENTS, 失败 $FAILED_DEPLOYMENTS"
|
||||
|
||||
# 根据部署结果设置退出码
|
||||
if [ $FAILED_DEPLOYMENTS -eq 0 ]; then
|
||||
log_success "🎉 远程部署任务完成!总耗时: ${total_time}s"
|
||||
exit 0
|
||||
else
|
||||
log_warning "⚠️ 部分服务部署失败,总耗时: ${total_time}s"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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"]
|
||||
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/bin/bash
|
||||
|
||||
# emotion-ai 单独部署脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
|
||||
set -e
|
||||
|
||||
# 配置变量
|
||||
SERVICE_NAME="emotion-ai"
|
||||
SERVICE_PORT="19002"
|
||||
REMOTE_HOST="'root@47.111.10.27'"
|
||||
REMOTE_BUILD_DIR="/data/builds"
|
||||
REMOTE_DOCKER_COMPOSE_DIR="/data/docker"
|
||||
PROFILE="test"
|
||||
PROJECT_NAME="emotion-museum"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# 检查远程服务器连接
|
||||
check_remote_connection() {
|
||||
log_info "检查远程服务器连接..."
|
||||
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_success "远程服务器连接正常"
|
||||
else
|
||||
log_error "无法连接到远程服务器 'root@47.111.10.27'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 构建服务
|
||||
build_service() {
|
||||
log_info "构建服务: $SERVICE_NAME"
|
||||
|
||||
# 构建父项目依赖
|
||||
cd ..
|
||||
mvn clean install -DskipTests -q
|
||||
cd emotion-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@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${SERVICE_NAME} << 'EOF'
|
||||
# 使用阿里云镜像源的OpenJDK
|
||||
# 使用Java 17 Alpine镜像
|
||||
FROM openjdk:17-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具 (Alpine Linux使用apk)
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY ${SERVICE_NAME}-1.0.0.jar app.jar
|
||||
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone
|
||||
|
||||
EXPOSE ${SERVICE_PORT}
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\
|
||||
CMD curl -f http://localhost:${SERVICE_PORT}/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT [\"java\", \"-Djava.security.egd=file:/dev/./urandom\", \"-Xms512m\", \"-Xmx1024m\", \"-jar\", \"app.jar\"]
|
||||
EOF"
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
deploy_service() {
|
||||
log_info "开始部署服务: $SERVICE_NAME"
|
||||
|
||||
# 检查jar包
|
||||
local jar_file="target/${SERVICE_NAME}-1.0.0.jar"
|
||||
if [ ! -f "$jar_file" ]; then
|
||||
log_error "JAR包不存在: $jar_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建远程目录
|
||||
ssh 'root@47.111.10.27' "
|
||||
mkdir -p $REMOTE_BUILD_DIR
|
||||
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
|
||||
mkdir -p /data/logs/emotion-museum
|
||||
"
|
||||
|
||||
# 删除旧jar包
|
||||
log_info "删除远程旧jar包"
|
||||
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
|
||||
|
||||
# 上传新jar包
|
||||
log_info "上传jar包"
|
||||
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar; then
|
||||
log_success "jar包上传成功"
|
||||
else
|
||||
log_error "jar包上传失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile
|
||||
|
||||
# 停止旧容器
|
||||
log_info "停止旧容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker stop ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rm ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rmi ${PROJECT_NAME}/${SERVICE_NAME}:latest 2>/dev/null || true
|
||||
"
|
||||
|
||||
# 创建Docker网络
|
||||
ssh 'root@47.111.10.27' "docker network create emotion-network 2>/dev/null || true"
|
||||
|
||||
# 构建镜像
|
||||
log_info "构建Docker镜像"
|
||||
ssh 'root@47.111.10.27' "
|
||||
cd $REMOTE_DOCKER_COMPOSE_DIR
|
||||
# 复制jar包到Docker构建目录
|
||||
cp $REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
|
||||
|
||||
# 构建镜像 docker build -t ${PROJECT_NAME}/${SERVICE_NAME}:latest -f Dockerfile.${SERVICE_NAME} .
|
||||
|
||||
# 清理临时文件
|
||||
rm -f ${SERVICE_NAME}-1.0.0.jar "
|
||||
|
||||
# 启动容器
|
||||
log_info "启动新容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker run -d \\
|
||||
--name ${SERVICE_NAME} \\
|
||||
--network emotion-network \\
|
||||
-p ${SERVICE_PORT}:${SERVICE_PORT} \\
|
||||
-v /data/logs/emotion-museum:/app/logs \\
|
||||
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
|
||||
-e MYSQL_HOST=47.111.10.27 \\
|
||||
-e MYSQL_PORT=3306 \\
|
||||
-e MYSQL_DATABASE=emotion_museum \\
|
||||
-e MYSQL_USERNAME=root \\
|
||||
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
|
||||
-e REDIS_HOST=47.111.10.27 \\
|
||||
-e REDIS_PORT=6379 \\
|
||||
-e REDIS_PASSWORD= \\
|
||||
-e REDIS_DATABASE=0 \\
|
||||
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
|
||||
-e NACOS_USERNAME=nacos \\
|
||||
-e NACOS_PASSWORD='Peanut2817*#' \\
|
||||
--restart unless-stopped \\
|
||||
${PROJECT_NAME}/${SERVICE_NAME}:latest
|
||||
"
|
||||
|
||||
# 等待启动
|
||||
log_info "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 检查状态
|
||||
if ssh 'root@47.111.10.27' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
|
||||
log_success "服务启动成功"
|
||||
|
||||
# 显示日志
|
||||
log_info "服务日志 最后20行:"
|
||||
ssh 'root@47.111.10.27' "docker logs --tail 20 ${SERVICE_NAME}"
|
||||
|
||||
# 健康检查
|
||||
log_info "执行健康检查..."
|
||||
sleep 10
|
||||
if ssh 'root@47.111.10.27' "curl -f -s http://localhost:${SERVICE_PORT}/actuator/health" > /dev/null 2>&1; then
|
||||
log_success "健康检查通过"
|
||||
else
|
||||
log_warning "健康检查失败,服务可能仍在启动中"
|
||||
fi
|
||||
else
|
||||
log_error "服务启动失败"
|
||||
ssh 'root@47.111.10.27' "docker logs ${SERVICE_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
log_info "开始部署 $SERVICE_NAME 服务"
|
||||
log_info "目标服务器: $REMOTE_HOST"
|
||||
log_info "服务端口: $SERVICE_PORT"
|
||||
log_info "部署环境: $PROFILE"
|
||||
|
||||
check_remote_connection
|
||||
build_service
|
||||
deploy_service
|
||||
|
||||
log_success "$SERVICE_NAME 服务部署完成!"
|
||||
log_info "访问地址: http://47.111.10.27:$SERVICE_PORT"
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.emotionmuseum</groupId>
|
||||
<artifactId>backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>emotion-ai</artifactId>
|
||||
<name>emotion-ai</name>
|
||||
<description>AI对话服务</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- 内部模块依赖 -->
|
||||
<dependency>
|
||||
<groupId>com.emotionmuseum</groupId>
|
||||
<artifactId>emotion-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cloud Discovery -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-bootstrap</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot DevTools for automatic restart -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenFeign -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL -->
|
||||
<dependency>
|
||||
<groupId>mysql</groupId>
|
||||
<artifactId>mysql-connector-java</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Druid -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>druid-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 暂时移除Spring AI,使用原生HTTP客户端实现 -->
|
||||
|
||||
<!-- HTTP客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</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>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<configuration>
|
||||
<mainClass>com.emotionmuseum.ai.AiApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,24 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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 emotion-museum
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.emotionmuseum.ai.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 功能开关配置
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package com.emotionmuseum.ai.controller;
|
||||
|
||||
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.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 emotion-museum
|
||||
* @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<CreateConversationResponse> createConversation(
|
||||
@Valid @RequestBody CreateConversationRequest request) {
|
||||
log.info("收到创建会话请求: userId={}, title={}", request.getUserId(), request.getTitle());
|
||||
|
||||
CreateConversationResponse response = aiChatService.createConversation(request);
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "发送聊天消息")
|
||||
@PostMapping("/send")
|
||||
public Result<ChatResponse> sendMessage(@Valid @RequestBody ChatRequest request) {
|
||||
log.info("收到聊天请求: userId={}, message={}", request.getUserId(), request.getMessage());
|
||||
|
||||
ChatResponse response = aiChatService.chat(request);
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "情绪分析")
|
||||
@PostMapping("/emotion/analyze")
|
||||
public Result<EmotionAnalysisResponse> analyzeEmotion(@Valid @RequestBody EmotionAnalysisRequest request) {
|
||||
log.info("收到情绪分析请求: userId={}, text={}", request.getUserId(), request.getText());
|
||||
|
||||
EmotionAnalysisResponse response = aiChatService.analyzeEmotion(request);
|
||||
return Result.success(response);
|
||||
}
|
||||
|
||||
@Operation(summary = "流式聊天")
|
||||
@PostMapping("/stream")
|
||||
public Result<String> streamChat(@Valid @RequestBody 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<Object> 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" +
|
||||
"这是第三段回复,介绍了情感分析功能。我可以分析你的情绪状态并提供建议。";
|
||||
|
||||
// 创建或获取会话
|
||||
CreateConversationRequest convRequest = new CreateConversationRequest();
|
||||
convRequest.setUserId(request.getUserId());
|
||||
convRequest.setTitle("测试拆分消息");
|
||||
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);
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.emotionmuseum.ai.controller;
|
||||
|
||||
import com.emotionmuseum.ai.dto.*;
|
||||
import com.emotionmuseum.ai.service.GuestChatService;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 访客聊天控制器
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-13
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/ai/guest")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "访客聊天", description = "访客模式AI聊天接口")
|
||||
public class GuestChatController {
|
||||
|
||||
private final GuestChatService guestChatService;
|
||||
|
||||
@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 guestChatService.getGuestConversations(clientIp, pageNum, pageSize);
|
||||
}
|
||||
|
||||
@GetMapping("/conversation/{conversationId}/messages")
|
||||
@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 guestChatService.getGuestConversationMessages(conversationId, clientIp, pageNum, pageSize);
|
||||
}
|
||||
|
||||
@PostMapping("/conversation/{conversationId}/end")
|
||||
@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.getOrCreateGuestUser(clientIp, userAgent);
|
||||
}
|
||||
|
||||
@PostMapping("/emotion/analyze")
|
||||
@Operation(summary = "访客情绪分析", description = "分析访客输入文本的情绪")
|
||||
public Result<EmotionAnalysisResponse> analyzeGuestEmotion(
|
||||
@RequestBody EmotionAnalysisRequest request) {
|
||||
|
||||
String clientIp = getClientIp();
|
||||
log.info("访客情绪分析: IP={}, Text={}", clientIp, request.getText());
|
||||
|
||||
return guestChatService.analyzeGuestEmotion(request, clientIp);
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@Operation(summary = "访客服务健康检查", description = "检查访客聊天服务状态")
|
||||
public Result<Boolean> healthCheck() {
|
||||
return Result.success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/test/split")
|
||||
@Operation(summary = "测试消息拆分功能", description = "测试AI回复消息的拆分功能")
|
||||
public Result<GuestChatResponse> testMessageSplit(@RequestBody GuestChatRequest request) {
|
||||
log.info("测试消息拆分功能: message={}", request.getMessage());
|
||||
|
||||
// 模拟包含不同换行符的AI回复进行测试
|
||||
String mockAiReply;
|
||||
if (request.getMessage().contains("双换行")) {
|
||||
mockAiReply = "这是第一段回复,介绍了基本功能。我可以帮助你进行日常对话。\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();
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_CLIENT_IP");
|
||||
}
|
||||
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||
}
|
||||
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// 处理多个IP的情况,取第一个
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.emotionmuseum.ai.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会话列表响应DTO
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.emotionmuseum.ai.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 创建会话请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.emotionmuseum.ai.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 访客聊天请求DTO
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.emotionmuseum.ai.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 访客聊天响应DTO
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.emotionmuseum.ai.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Builder;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 访客用户信息DTO
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.emotionmuseum.ai.service;
|
||||
|
||||
import com.emotionmuseum.ai.dto.*;
|
||||
import com.emotionmuseum.ai.entity.Message;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI聊天服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.emotionmuseum.ai.service;
|
||||
|
||||
import com.emotionmuseum.ai.dto.*;
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 访客聊天服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.emotionmuseum.ai.service;
|
||||
|
||||
import com.emotionmuseum.ai.dto.GuestUserInfo;
|
||||
|
||||
/**
|
||||
* 访客用户服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+800
@@ -0,0 +1,800 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
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 emotion-museum
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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
|
||||
@@ -0,0 +1,82 @@
|
||||
# 本地开发环境配置
|
||||
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
# 生产环境配置
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: prod
|
||||
group: DEFAULT_GROUP
|
||||
enabled: true
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
zone: prod
|
||||
register-enabled: true
|
||||
ephemeral: true
|
||||
cluster-name: DEFAULT
|
||||
service: ${spring.application.name}
|
||||
weight: 1
|
||||
heart-beat-interval: 5000
|
||||
heart-beat-timeout: 15000
|
||||
ip-delete-timeout: 30000
|
||||
config:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: prod
|
||||
group: DEFAULT_GROUP
|
||||
file-extension: yml
|
||||
enabled: false
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://47.111.10.27:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: 47.111.10.27
|
||||
port: 6379
|
||||
password: EmotionMuseum2025*#
|
||||
database: 0
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: warn
|
||||
com.baomidou.mybatisplus: warn
|
||||
com.alibaba.nacos: error
|
||||
file:
|
||||
name: logs/emotion-ai-prod.log
|
||||
@@ -0,0 +1,55 @@
|
||||
# 测试环境配置
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: test
|
||||
group: DEFAULT_GROUP
|
||||
enabled: true
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
zone: test
|
||||
register-enabled: true
|
||||
ephemeral: true
|
||||
cluster-name: DEFAULT
|
||||
service: ${spring.application.name}
|
||||
weight: 1
|
||||
heart-beat-interval: 5000
|
||||
heart-beat-timeout: 15000
|
||||
ip-delete-timeout: 30000
|
||||
config:
|
||||
server-addr: 47.111.10.27:8848
|
||||
namespace: test
|
||||
group: DEFAULT_GROUP
|
||||
file-extension: yml
|
||||
enabled: false
|
||||
username: nacos
|
||||
password: EmotionMuseum2025
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://47.111.10.27:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: 47.111.10.27
|
||||
port: 6379
|
||||
password: EmotionMuseum2025*#
|
||||
database: 0
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: info
|
||||
com.baomidou.mybatisplus: info
|
||||
com.alibaba.nacos: warn
|
||||
file:
|
||||
name: logs/emotion-ai-test.log
|
||||
@@ -0,0 +1,128 @@
|
||||
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"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# 认证服务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"]
|
||||
Executable
+226
@@ -0,0 +1,226 @@
|
||||
#!/bin/bash
|
||||
|
||||
# emotion-auth 单独部署脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
|
||||
set -e
|
||||
|
||||
# 配置变量
|
||||
SERVICE_NAME="emotion-auth"
|
||||
SERVICE_PORT=""
|
||||
REMOTE_HOST="'root@47.111.10.27'"
|
||||
REMOTE_BUILD_DIR="/data/builds"
|
||||
REMOTE_DOCKER_COMPOSE_DIR="/data/docker"
|
||||
PROFILE="test"
|
||||
PROJECT_NAME="emotion-museum"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# 检查远程服务器连接
|
||||
check_remote_connection() {
|
||||
log_info "检查远程服务器连接..."
|
||||
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_success "远程服务器连接正常"
|
||||
else
|
||||
log_error "无法连接到远程服务器 'root@47.111.10.27'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 构建服务
|
||||
build_service() {
|
||||
log_info "构建服务: $SERVICE_NAME"
|
||||
|
||||
# 构建父项目依赖
|
||||
cd ..
|
||||
mvn clean install -DskipTests -q
|
||||
cd emotion-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@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${SERVICE_NAME} << 'EOF'
|
||||
# 使用阿里云镜像源的OpenJDK
|
||||
# 使用Java 17 Alpine镜像
|
||||
FROM openjdk:17-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具 (Alpine Linux使用apk)
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY ${SERVICE_NAME}-1.0.0.jar app.jar
|
||||
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone
|
||||
|
||||
EXPOSE ${SERVICE_PORT}
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\
|
||||
CMD curl -f http://localhost:${SERVICE_PORT}/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT [\"java\", \"-Djava.security.egd=file:/dev/./urandom\", \"-Xms512m\", \"-Xmx1024m\", \"-jar\", \"app.jar\"]
|
||||
EOF"
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
deploy_service() {
|
||||
log_info "开始部署服务: $SERVICE_NAME"
|
||||
|
||||
# 检查jar包
|
||||
local jar_file="target/${SERVICE_NAME}-1.0.0.jar"
|
||||
if [ ! -f "$jar_file" ]; then
|
||||
log_error "JAR包不存在: $jar_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建远程目录
|
||||
ssh 'root@47.111.10.27' "
|
||||
mkdir -p $REMOTE_BUILD_DIR
|
||||
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
|
||||
mkdir -p /data/logs/emotion-museum
|
||||
"
|
||||
|
||||
# 删除旧jar包
|
||||
log_info "删除远程旧jar包"
|
||||
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
|
||||
|
||||
# 上传新jar包
|
||||
log_info "上传jar包"
|
||||
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar; then
|
||||
log_success "jar包上传成功"
|
||||
else
|
||||
log_error "jar包上传失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile
|
||||
|
||||
# 停止旧容器
|
||||
log_info "停止旧容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker stop ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rm ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rmi ${PROJECT_NAME}/${SERVICE_NAME}:latest 2>/dev/null || true
|
||||
"
|
||||
|
||||
# 创建Docker网络
|
||||
ssh 'root@47.111.10.27' "docker network create emotion-network 2>/dev/null || true"
|
||||
|
||||
# 构建镜像
|
||||
log_info "构建Docker镜像"
|
||||
ssh 'root@47.111.10.27' "
|
||||
cd $REMOTE_DOCKER_COMPOSE_DIR
|
||||
# 复制jar包到Docker构建目录
|
||||
cp $REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
|
||||
|
||||
# 构建镜像 docker build -t ${PROJECT_NAME}/${SERVICE_NAME}:latest -f Dockerfile.${SERVICE_NAME} .
|
||||
|
||||
# 清理临时文件
|
||||
rm -f ${SERVICE_NAME}-1.0.0.jar "
|
||||
|
||||
# 启动容器
|
||||
log_info "启动新容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker run -d \\
|
||||
--name ${SERVICE_NAME} \\
|
||||
--network emotion-network \\
|
||||
-p ${SERVICE_PORT}:${SERVICE_PORT} \\
|
||||
-v /data/logs/emotion-museum:/app/logs \\
|
||||
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
|
||||
-e MYSQL_HOST=47.111.10.27 \\
|
||||
-e MYSQL_PORT=3306 \\
|
||||
-e MYSQL_DATABASE=emotion_museum \\
|
||||
-e MYSQL_USERNAME=root \\
|
||||
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
|
||||
-e REDIS_HOST=47.111.10.27 \\
|
||||
-e REDIS_PORT=6379 \\
|
||||
-e REDIS_PASSWORD= \\
|
||||
-e REDIS_DATABASE=0 \\
|
||||
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
|
||||
-e NACOS_USERNAME=nacos \\
|
||||
-e NACOS_PASSWORD='Peanut2817*#' \\
|
||||
--restart unless-stopped \\
|
||||
${PROJECT_NAME}/${SERVICE_NAME}:latest
|
||||
"
|
||||
|
||||
# 等待启动
|
||||
log_info "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 检查状态
|
||||
if ssh 'root@47.111.10.27' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
|
||||
log_success "服务启动成功"
|
||||
|
||||
# 显示日志
|
||||
log_info "服务日志 最后20行:"
|
||||
ssh 'root@47.111.10.27' "docker logs --tail 20 ${SERVICE_NAME}"
|
||||
|
||||
# 健康检查
|
||||
log_info "执行健康检查..."
|
||||
sleep 10
|
||||
if ssh 'root@47.111.10.27' "curl -f -s http://localhost:${SERVICE_PORT}/actuator/health" > /dev/null 2>&1; then
|
||||
log_success "健康检查通过"
|
||||
else
|
||||
log_warning "健康检查失败,服务可能仍在启动中"
|
||||
fi
|
||||
else
|
||||
log_error "服务启动失败"
|
||||
ssh 'root@47.111.10.27' "docker logs ${SERVICE_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
log_info "开始部署 $SERVICE_NAME 服务"
|
||||
log_info "目标服务器: $REMOTE_HOST"
|
||||
log_info "服务端口: $SERVICE_PORT"
|
||||
log_info "部署环境: $PROFILE"
|
||||
|
||||
check_remote_connection
|
||||
build_service
|
||||
deploy_service
|
||||
|
||||
log_success "$SERVICE_NAME 服务部署完成!"
|
||||
log_info "访问地址: http://47.111.10.27:$SERVICE_PORT"
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,144 @@
|
||||
<?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>emotion-auth</artifactId>
|
||||
<name>emotion-auth</name>
|
||||
<description>情感博物馆认证授权服务</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- 公共模块 -->
|
||||
<dependency>
|
||||
<groupId>com.emotionmuseum</groupId>
|
||||
<artifactId>emotion-common</artifactId>
|
||||
<version>1.0.0</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>
|
||||
|
||||
<!-- Nacos配置中心 -->
|
||||
<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>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
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 emotion-museum
|
||||
* @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());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
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 emotion-museum
|
||||
* @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",
|
||||
"/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();
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.emotionmuseum.auth.controller;
|
||||
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.auth.dto.LoginRequest;
|
||||
import com.emotionmuseum.auth.dto.RegisterRequest;
|
||||
import com.emotionmuseum.auth.service.AuthService;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import com.emotionmuseum.auth.vo.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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.emotionmuseum.auth.controller;
|
||||
|
||||
import com.emotionmuseum.common.result.Result;
|
||||
import com.emotionmuseum.auth.dto.CaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 验证码响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 用户登录请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 第三方登录请求
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.emotionmuseum.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 滑块验证码响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
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 emotion-museum
|
||||
* @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");
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
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 emotion-museum
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.LoginRequest;
|
||||
import com.emotionmuseum.auth.dto.RegisterRequest;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import com.emotionmuseum.auth.vo.UserInfoResponse;
|
||||
|
||||
/**
|
||||
* 认证服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.CaptchaResponse;
|
||||
|
||||
/**
|
||||
* 验证码服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
|
||||
/**
|
||||
* 第三方登录服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @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);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.emotionmuseum.auth.service;
|
||||
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaVerifyRequest;
|
||||
|
||||
/**
|
||||
* 滑块验证码服务接口
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
public interface SliderCaptchaService {
|
||||
|
||||
/**
|
||||
* 生成滑块验证码
|
||||
*
|
||||
* @return 滑块验证码响应
|
||||
*/
|
||||
SliderCaptchaResponse generateSliderCaptcha();
|
||||
|
||||
/**
|
||||
* 验证滑块验证码
|
||||
*
|
||||
* @param request 验证请求
|
||||
* @return 是否验证成功
|
||||
*/
|
||||
boolean verifySliderCaptcha(SliderCaptchaVerifyRequest request);
|
||||
|
||||
/**
|
||||
* 删除滑块验证码
|
||||
*
|
||||
* @param captchaId 验证码ID
|
||||
*/
|
||||
void removeSliderCaptcha(String captchaId);
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
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.dto.LoginRequest;
|
||||
import com.emotionmuseum.auth.dto.RegisterRequest;
|
||||
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.vo.LoginResponse;
|
||||
import com.emotionmuseum.auth.vo.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 emotion-museum
|
||||
* @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);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.emotionmuseum.auth.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.emotionmuseum.auth.dto.CaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.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 emotion-museum
|
||||
* @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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.emotionmuseum.auth.service.impl;
|
||||
|
||||
import com.emotionmuseum.auth.dto.OAuthLoginRequest;
|
||||
import com.emotionmuseum.auth.service.OAuthService;
|
||||
import com.emotionmuseum.auth.vo.LoginResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 第三方登录服务实现类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class OAuthServiceImpl implements OAuthService {
|
||||
|
||||
@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;
|
||||
|
||||
@Override
|
||||
public String getAuthUrl(String platform) {
|
||||
log.info("获取第三方登录授权URL, platform: {}", platform);
|
||||
|
||||
switch (platform.toLowerCase()) {
|
||||
case "wechat":
|
||||
return buildWechatAuthUrl();
|
||||
case "qq":
|
||||
return buildQQAuthUrl();
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的第三方平台: " + platform);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse oauthLogin(OAuthLoginRequest request) {
|
||||
log.info("第三方登录, platform: {}, code: {}", request.getPlatform(), request.getCode());
|
||||
|
||||
// TODO: 实现第三方登录逻辑
|
||||
// 1. 根据code获取access_token
|
||||
// 2. 根据access_token获取用户信息
|
||||
// 3. 查询或创建用户
|
||||
// 4. 生成JWT token
|
||||
|
||||
throw new UnsupportedOperationException("第三方登录功能暂未实现");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getOAuthUserInfo(String platform, String code, String state) {
|
||||
log.info("获取第三方用户信息, platform: {}, code: {}, state: {}", platform, code, state);
|
||||
|
||||
// TODO: 实现获取第三方用户信息逻辑
|
||||
|
||||
throw new UnsupportedOperationException("获取第三方用户信息功能暂未实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建微信授权URL
|
||||
*/
|
||||
private String buildWechatAuthUrl() {
|
||||
if (wechatClientId.isEmpty()) {
|
||||
throw new IllegalStateException("微信OAuth配置未完成");
|
||||
}
|
||||
|
||||
// TODO: 构建微信授权URL
|
||||
return "https://open.weixin.qq.com/connect/oauth2/authorize?appid=" + wechatClientId +
|
||||
"&redirect_uri=" + wechatRedirectUri +
|
||||
"&response_type=code&scope=snsapi_userinfo&state=wechat#wechat_redirect";
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建QQ授权URL
|
||||
*/
|
||||
private String buildQQAuthUrl() {
|
||||
if (qqClientId.isEmpty()) {
|
||||
throw new IllegalStateException("QQ OAuth配置未完成");
|
||||
}
|
||||
|
||||
// TODO: 构建QQ授权URL
|
||||
return "https://graph.qq.com/oauth2.0/authorize?response_type=code&client_id=" + qqClientId +
|
||||
"&redirect_uri=" + qqRedirectUri +
|
||||
"&state=qq&scope=get_user_info";
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package com.emotionmuseum.auth.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaResponse;
|
||||
import com.emotionmuseum.auth.dto.SliderCaptchaVerifyRequest;
|
||||
import com.emotionmuseum.auth.service.SliderCaptchaService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 滑块验证码服务实现
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-15
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SliderCaptchaServiceImpl implements SliderCaptchaService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String SLIDER_CAPTCHA_KEY_PREFIX = "slider_captcha:";
|
||||
private static final long SLIDER_CAPTCHA_EXPIRE_TIME = 300; // 5分钟
|
||||
private static final int BACKGROUND_WIDTH = 300;
|
||||
private static final int BACKGROUND_HEIGHT = 150;
|
||||
private static final int SLIDER_WIDTH = 60;
|
||||
private static final int SLIDER_HEIGHT = 60;
|
||||
private static final int TOLERANCE = 5; // 容错范围
|
||||
|
||||
@Override
|
||||
public SliderCaptchaResponse generateSliderCaptcha() {
|
||||
try {
|
||||
String captchaId = IdUtil.simpleUUID();
|
||||
|
||||
// 生成随机位置
|
||||
Random random = new Random();
|
||||
int sliderX = random.nextInt(BACKGROUND_WIDTH - SLIDER_WIDTH - 50) + 50;
|
||||
int sliderY = random.nextInt(BACKGROUND_HEIGHT - SLIDER_HEIGHT - 20) + 20;
|
||||
|
||||
// 生成背景图片
|
||||
BufferedImage backgroundImage = generateBackgroundImage(sliderX, sliderY);
|
||||
String backgroundBase64 = imageToBase64(backgroundImage);
|
||||
|
||||
// 生成滑块图片
|
||||
BufferedImage sliderImage = generateSliderImage();
|
||||
String sliderBase64 = imageToBase64(sliderImage);
|
||||
|
||||
// 存储到Redis
|
||||
String redisKey = SLIDER_CAPTCHA_KEY_PREFIX + captchaId;
|
||||
SliderCaptchaData data = new SliderCaptchaData(sliderX, sliderY);
|
||||
redisTemplate.opsForValue().set(redisKey, data, SLIDER_CAPTCHA_EXPIRE_TIME, TimeUnit.SECONDS);
|
||||
|
||||
log.debug("生成滑块验证码成功,ID: {}, 位置: ({}, {})", captchaId, sliderX, sliderY);
|
||||
|
||||
return new SliderCaptchaResponse(captchaId, backgroundBase64, sliderBase64, 0, sliderY, SLIDER_CAPTCHA_EXPIRE_TIME);
|
||||
} catch (Exception e) {
|
||||
log.error("生成滑块验证码失败: {}", e.getMessage());
|
||||
throw new RuntimeException("生成滑块验证码失败");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifySliderCaptcha(SliderCaptchaVerifyRequest request) {
|
||||
if (StrUtil.isBlank(request.getCaptchaId()) || request.getX() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String redisKey = SLIDER_CAPTCHA_KEY_PREFIX + request.getCaptchaId();
|
||||
SliderCaptchaData data = (SliderCaptchaData) redisTemplate.opsForValue().get(redisKey);
|
||||
|
||||
if (data == null) {
|
||||
log.warn("滑块验证码已过期或不存在,ID: {}", request.getCaptchaId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证X坐标是否在容错范围内
|
||||
boolean isValid = Math.abs(data.getSliderX() - request.getX()) <= TOLERANCE;
|
||||
|
||||
if (isValid) {
|
||||
// 验证成功后删除验证码
|
||||
redisTemplate.delete(redisKey);
|
||||
log.debug("滑块验证码验证成功,ID: {}", request.getCaptchaId());
|
||||
} else {
|
||||
log.warn("滑块验证码验证失败,ID: {}, 期望X: {}, 实际X: {}",
|
||||
request.getCaptchaId(), data.getSliderX(), request.getX());
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (Exception e) {
|
||||
log.error("验证滑块验证码失败: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeSliderCaptcha(String captchaId) {
|
||||
if (StrUtil.isNotBlank(captchaId)) {
|
||||
String redisKey = SLIDER_CAPTCHA_KEY_PREFIX + captchaId;
|
||||
redisTemplate.delete(redisKey);
|
||||
log.debug("删除滑块验证码,ID: {}", captchaId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成背景图片
|
||||
*/
|
||||
private BufferedImage generateBackgroundImage(int sliderX, int sliderY) {
|
||||
BufferedImage image = new BufferedImage(BACKGROUND_WIDTH, BACKGROUND_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
// 设置抗锯齿
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 绘制渐变背景
|
||||
GradientPaint gradient = new GradientPaint(0, 0, new Color(135, 206, 250),
|
||||
BACKGROUND_WIDTH, BACKGROUND_HEIGHT, new Color(70, 130, 180));
|
||||
g2d.setPaint(gradient);
|
||||
g2d.fillRect(0, 0, BACKGROUND_WIDTH, BACKGROUND_HEIGHT);
|
||||
|
||||
// 绘制一些装饰性图形
|
||||
Random random = new Random();
|
||||
g2d.setColor(new Color(255, 255, 255, 100));
|
||||
for (int i = 0; i < 20; i++) {
|
||||
int x = random.nextInt(BACKGROUND_WIDTH);
|
||||
int y = random.nextInt(BACKGROUND_HEIGHT);
|
||||
int size = random.nextInt(20) + 5;
|
||||
g2d.fillOval(x, y, size, size);
|
||||
}
|
||||
|
||||
// 绘制滑块缺口
|
||||
g2d.setColor(new Color(0, 0, 0, 150));
|
||||
g2d.fillRoundRect(sliderX, sliderY, SLIDER_WIDTH, SLIDER_HEIGHT, 10, 10);
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成滑块图片
|
||||
*/
|
||||
private BufferedImage generateSliderImage() {
|
||||
BufferedImage image = new BufferedImage(SLIDER_WIDTH, SLIDER_HEIGHT, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
// 设置抗锯齿
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 绘制滑块
|
||||
g2d.setColor(new Color(70, 130, 180));
|
||||
g2d.fillRoundRect(0, 0, SLIDER_WIDTH, SLIDER_HEIGHT, 10, 10);
|
||||
|
||||
// 绘制边框
|
||||
g2d.setColor(new Color(255, 255, 255));
|
||||
g2d.setStroke(new BasicStroke(2));
|
||||
g2d.drawRoundRect(1, 1, SLIDER_WIDTH - 2, SLIDER_HEIGHT - 2, 10, 10);
|
||||
|
||||
// 绘制箭头
|
||||
g2d.setColor(Color.WHITE);
|
||||
int[] xPoints = {SLIDER_WIDTH/2 - 8, SLIDER_WIDTH/2 + 8, SLIDER_WIDTH/2};
|
||||
int[] yPoints = {SLIDER_HEIGHT/2, SLIDER_HEIGHT/2, SLIDER_HEIGHT/2 - 8};
|
||||
g2d.fillPolygon(xPoints, yPoints, 3);
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片转Base64
|
||||
*/
|
||||
private String imageToBase64(BufferedImage image) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "PNG", baos);
|
||||
byte[] bytes = baos.toByteArray();
|
||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑块验证码数据
|
||||
*/
|
||||
public static class SliderCaptchaData {
|
||||
private int sliderX;
|
||||
private int sliderY;
|
||||
|
||||
public SliderCaptchaData() {}
|
||||
|
||||
public SliderCaptchaData(int sliderX, int sliderY) {
|
||||
this.sliderX = sliderX;
|
||||
this.sliderY = sliderY;
|
||||
}
|
||||
|
||||
public int getSliderX() { return sliderX; }
|
||||
public void setSliderX(int sliderX) { this.sliderX = sliderX; }
|
||||
public int getSliderY() { return sliderY; }
|
||||
public void setSliderY(int sliderY) { this.sliderY = sliderY; }
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.emotionmuseum.auth.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 登录响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "登录响应")
|
||||
public class LoginResponse {
|
||||
|
||||
@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;
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.emotionmuseum.auth.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户信息响应
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-16
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户信息响应")
|
||||
public class UserInfoResponse {
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
server:
|
||||
port: 19008
|
||||
|
||||
spring:
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://localhost:3306/emotion_museum?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: 123456
|
||||
|
||||
# 连接池配置
|
||||
hikari:
|
||||
minimum-idle: 5
|
||||
maximum-pool-size: 20
|
||||
idle-timeout: 300000
|
||||
connection-timeout: 20000
|
||||
max-lifetime: 1200000
|
||||
pool-name: EmotionAuthHikariCP
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password:
|
||||
database: 0
|
||||
timeout: 5000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-idle: 10
|
||||
min-idle: 5
|
||||
max-wait: 2000ms
|
||||
|
||||
# 云服务配置
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: localhost:8848
|
||||
username: nacos
|
||||
password: Peanut2817*#
|
||||
config:
|
||||
server-addr: localhost:8848
|
||||
username: nacos
|
||||
password: Peanut2817*#
|
||||
|
||||
# MyBatis Plus配置
|
||||
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
|
||||
|
||||
# 监控配置
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: debug
|
||||
com.baomidou.mybatisplus: debug
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
|
||||
|
||||
# JWT配置
|
||||
jwt:
|
||||
secret: emotion-museum-secret-key-2025
|
||||
expiration: 86400
|
||||
refresh-expiration: 604800
|
||||
|
||||
# 验证码配置
|
||||
captcha:
|
||||
type: arithmetic
|
||||
length: 4
|
||||
expire-time: 300
|
||||
|
||||
# OAuth配置
|
||||
oauth:
|
||||
wechat:
|
||||
client-id:
|
||||
client-secret:
|
||||
redirect-uri:
|
||||
qq:
|
||||
client-id:
|
||||
client-secret:
|
||||
redirect-uri:
|
||||
@@ -0,0 +1,104 @@
|
||||
server:
|
||||
port: 19008
|
||||
|
||||
spring:
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: ${MYSQL_USERNAME}
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# 连接池配置
|
||||
hikari:
|
||||
minimum-idle: 10
|
||||
maximum-pool-size: 50
|
||||
idle-timeout: 300000
|
||||
connection-timeout: 20000
|
||||
max-lifetime: 1200000
|
||||
pool-name: EmotionAuthHikariCP
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT}
|
||||
password: ${REDIS_PASSWORD}
|
||||
database: ${REDIS_DATABASE}
|
||||
timeout: 5000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 50
|
||||
max-idle: 20
|
||||
min-idle: 10
|
||||
max-wait: 2000ms
|
||||
|
||||
# 云服务配置
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: EmotionMuseum2025*#
|
||||
config:
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# MyBatis Plus配置
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: assign_uuid
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
# 监控配置
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: info
|
||||
com.baomidou.mybatisplus: warn
|
||||
root: warn
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
|
||||
|
||||
# JWT配置
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400}
|
||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800}
|
||||
|
||||
# 验证码配置
|
||||
captcha:
|
||||
type: arithmetic
|
||||
length: 4
|
||||
expire-time: 300
|
||||
|
||||
# OAuth配置
|
||||
oauth:
|
||||
wechat:
|
||||
client-id: ${WECHAT_CLIENT_ID}
|
||||
client-secret: ${WECHAT_CLIENT_SECRET}
|
||||
redirect-uri: ${WECHAT_REDIRECT_URI}
|
||||
qq:
|
||||
client-id: ${QQ_CLIENT_ID}
|
||||
client-secret: ${QQ_CLIENT_SECRET}
|
||||
redirect-uri: ${QQ_REDIRECT_URI}
|
||||
@@ -0,0 +1,103 @@
|
||||
server:
|
||||
port: 19008
|
||||
|
||||
spring:
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: ${MYSQL_USERNAME}
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# 连接池配置
|
||||
hikari:
|
||||
minimum-idle: 5
|
||||
maximum-pool-size: 20
|
||||
idle-timeout: 300000
|
||||
connection-timeout: 20000
|
||||
max-lifetime: 1200000
|
||||
pool-name: EmotionAuthHikariCP
|
||||
|
||||
# Redis配置
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT}
|
||||
password: ${REDIS_PASSWORD}
|
||||
database: ${REDIS_DATABASE}
|
||||
timeout: 5000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-idle: 10
|
||||
min-idle: 5
|
||||
max-wait: 2000ms
|
||||
|
||||
# 云服务配置
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: EmotionMuseum2025*#
|
||||
config:
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: EmotionMuseum2025*#
|
||||
|
||||
# MyBatis Plus配置
|
||||
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
|
||||
|
||||
# 监控配置
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
metrics:
|
||||
export:
|
||||
prometheus:
|
||||
enabled: true
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.emotionmuseum: info
|
||||
com.baomidou.mybatisplus: info
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%logger{50}] - %msg%n"
|
||||
|
||||
# JWT配置
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:emotion-museum-secret-key-2025}
|
||||
expiration: ${JWT_EXPIRATION:86400}
|
||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800}
|
||||
|
||||
# 验证码配置
|
||||
captcha:
|
||||
type: arithmetic
|
||||
length: 4
|
||||
expire-time: 300
|
||||
|
||||
# OAuth配置
|
||||
oauth:
|
||||
wechat:
|
||||
client-id: ${WECHAT_CLIENT_ID}
|
||||
client-secret: ${WECHAT_CLIENT_SECRET}
|
||||
redirect-uri: ${WECHAT_REDIRECT_URI}
|
||||
qq:
|
||||
client-id: ${QQ_CLIENT_ID}
|
||||
client-secret: ${QQ_CLIENT_SECRET}
|
||||
redirect-uri: ${QQ_REDIRECT_URI}
|
||||
@@ -0,0 +1,23 @@
|
||||
spring:
|
||||
application:
|
||||
name: emotion-auth
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
enabled: false
|
||||
discovery:
|
||||
enabled: true
|
||||
server-addr: 47.111.10.27:8848
|
||||
username: nacos
|
||||
password: Peanut2817*#
|
||||
|
||||
|
||||
|
||||
logging:
|
||||
file:
|
||||
path: /data/logs/emotion-museum/auth
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.emotionmuseum.auth.mapper.UserMapper">
|
||||
|
||||
<!-- 根据账号查询用户 -->
|
||||
<select id="selectByAccount" resultType="com.emotionmuseum.auth.entity.User">
|
||||
SELECT * FROM user
|
||||
WHERE account = #{account} AND is_deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 根据邮箱查询用户 -->
|
||||
<select id="selectByEmail" resultType="com.emotionmuseum.auth.entity.User">
|
||||
SELECT * FROM user
|
||||
WHERE email = #{email} AND is_deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 根据手机号查询用户 -->
|
||||
<select id="selectByPhone" resultType="com.emotionmuseum.auth.entity.User">
|
||||
SELECT * FROM user
|
||||
WHERE phone = #{phone} AND is_deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 根据第三方登录信息查询用户 -->
|
||||
<select id="selectByOAuth" resultType="com.emotionmuseum.auth.entity.User">
|
||||
SELECT * FROM user
|
||||
WHERE oauth_platform = #{platform} AND oauth_id = #{oauthId} AND is_deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 更新最后活跃时间 -->
|
||||
<update id="updateLastActiveTime">
|
||||
UPDATE user
|
||||
SET last_active_time = NOW(), update_time = NOW()
|
||||
WHERE id = #{userId} AND is_deleted = 0
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.emotionmuseum</groupId>
|
||||
<artifactId>backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>emotion-common</artifactId>
|
||||
<name>emotion-common</name>
|
||||
<description>公共模块</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- MyBatis Plus -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 工具类 -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alibaba.fastjson2</groupId>
|
||||
<artifactId>fastjson2</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>
|
||||
|
||||
<!-- API文档 -->
|
||||
<dependency>
|
||||
<groupId>com.github.xiaoymin</groupId>
|
||||
<artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.emotionmuseum.common.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.config.GlobalConfig;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.emotionmuseum.common.handler.EmotionMetaObjectHandler;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 配置类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 拦截器配置
|
||||
* 添加分页插件
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
|
||||
// 分页插件
|
||||
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
|
||||
paginationInnerInterceptor.setDbType(DbType.MYSQL);
|
||||
paginationInnerInterceptor.setOverflow(false);
|
||||
paginationInnerInterceptor.setMaxLimit(1000L);
|
||||
interceptor.addInnerInterceptor(paginationInnerInterceptor);
|
||||
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局配置
|
||||
*/
|
||||
@Bean
|
||||
public GlobalConfig globalConfig() {
|
||||
GlobalConfig globalConfig = new GlobalConfig();
|
||||
|
||||
// 设置元数据处理器
|
||||
globalConfig.setMetaObjectHandler(new EmotionMetaObjectHandler());
|
||||
|
||||
// 设置逻辑删除配置
|
||||
GlobalConfig.DbConfig dbConfig = new GlobalConfig.DbConfig();
|
||||
dbConfig.setLogicDeleteField("is_deleted"); // 逻辑删除字段名
|
||||
dbConfig.setLogicDeleteValue("1"); // 删除值
|
||||
dbConfig.setLogicNotDeleteValue("0"); // 未删除值
|
||||
globalConfig.setDbConfig(dbConfig);
|
||||
|
||||
return globalConfig;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.emotionmuseum.common.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* RestTemplate配置类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
/**
|
||||
* 默认RestTemplate
|
||||
*/
|
||||
@Bean("restTemplate")
|
||||
public RestTemplate restTemplate(RestTemplateBuilder builder) {
|
||||
return builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.interceptors(Collections.singletonList(loggingInterceptor()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 长连接RestTemplate(用于AI接口等耗时操作)
|
||||
*/
|
||||
@Bean("longRestTemplate")
|
||||
public RestTemplate longRestTemplate(RestTemplateBuilder builder) {
|
||||
return builder
|
||||
.setConnectTimeout(Duration.ofSeconds(60))
|
||||
.setReadTimeout(Duration.ofSeconds(300))
|
||||
.interceptors(Collections.singletonList(loggingInterceptor()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速RestTemplate(用于内部服务调用)
|
||||
*/
|
||||
@Bean("fastRestTemplate")
|
||||
public RestTemplate fastRestTemplate(RestTemplateBuilder builder) {
|
||||
return builder
|
||||
.setConnectTimeout(Duration.ofSeconds(5))
|
||||
.setReadTimeout(Duration.ofSeconds(10))
|
||||
.interceptors(Collections.singletonList(loggingInterceptor()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求日志拦截器
|
||||
*/
|
||||
private ClientHttpRequestInterceptor loggingInterceptor() {
|
||||
return (request, body, execution) -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 记录请求信息
|
||||
log.debug("HTTP Request: {} {}", request.getMethod(), request.getURI());
|
||||
if (body.length > 0 && body.length < 1000) {
|
||||
log.debug("Request Body: {}", new String(body));
|
||||
}
|
||||
|
||||
ClientHttpResponse response = null;
|
||||
try {
|
||||
response = execution.execute(request, body);
|
||||
|
||||
// 记录响应信息
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.debug("HTTP Response: {} {} - {}ms",
|
||||
response.getStatusCode().value(),
|
||||
response.getStatusText(),
|
||||
duration);
|
||||
|
||||
return response;
|
||||
} catch (IOException e) {
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.error("HTTP Request failed: {} {} - {}ms, Error: {}",
|
||||
request.getMethod(),
|
||||
request.getURI(),
|
||||
duration,
|
||||
e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.emotionmuseum.common.config;
|
||||
|
||||
import com.emotionmuseum.common.util.SnowflakeIdGenerator;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.util.Enumeration;
|
||||
|
||||
/**
|
||||
* 雪花算法配置类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-13
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class SnowflakeConfig {
|
||||
|
||||
/**
|
||||
* 机器ID配置,可通过配置文件指定
|
||||
*/
|
||||
@Value("${snowflake.machine-id:#{null}}")
|
||||
private Long configuredMachineId;
|
||||
|
||||
/**
|
||||
* 创建雪花算法ID生成器Bean
|
||||
*
|
||||
* @return SnowflakeIdGenerator实例
|
||||
*/
|
||||
@Bean
|
||||
public SnowflakeIdGenerator snowflakeIdGenerator() {
|
||||
long machineId = getMachineId();
|
||||
log.info("雪花算法配置完成,使用机器ID: {}", machineId);
|
||||
return new SnowflakeIdGenerator(machineId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取机器ID
|
||||
* 优先级:配置文件 > 网络接口MAC地址 > 系统时间戳
|
||||
*
|
||||
* @return 机器ID
|
||||
*/
|
||||
private long getMachineId() {
|
||||
// 1. 优先使用配置文件中的机器ID
|
||||
if (configuredMachineId != null) {
|
||||
long machineId = configuredMachineId % 1024; // 确保在0-1023范围内
|
||||
log.info("使用配置文件中的机器ID: {} (原始值: {})", machineId, configuredMachineId);
|
||||
return machineId;
|
||||
}
|
||||
|
||||
// 2. 尝试使用网络接口MAC地址生成机器ID
|
||||
try {
|
||||
long machineId = getMachineIdFromMac();
|
||||
log.info("使用MAC地址生成的机器ID: {}", machineId);
|
||||
return machineId;
|
||||
} catch (Exception e) {
|
||||
log.warn("无法从MAC地址生成机器ID: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 3. 使用系统时间戳作为后备方案
|
||||
long machineId = System.currentTimeMillis() % 1024;
|
||||
log.info("使用系统时间戳生成的机器ID: {}", machineId);
|
||||
return machineId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从MAC地址生成机器ID
|
||||
*
|
||||
* @return 机器ID
|
||||
* @throws Exception 获取MAC地址失败
|
||||
*/
|
||||
private long getMachineIdFromMac() throws Exception {
|
||||
// 获取本机所有网络接口
|
||||
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
|
||||
|
||||
while (networkInterfaces.hasMoreElements()) {
|
||||
NetworkInterface networkInterface = networkInterfaces.nextElement();
|
||||
|
||||
// 跳过回环接口和虚拟接口
|
||||
if (networkInterface.isLoopback() || networkInterface.isVirtual() || !networkInterface.isUp()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] mac = networkInterface.getHardwareAddress();
|
||||
if (mac != null && mac.length >= 6) {
|
||||
// 使用MAC地址的后两个字节生成机器ID
|
||||
long machineId = ((long) (mac[mac.length - 2] & 0xFF) << 8)
|
||||
| (long) (mac[mac.length - 1] & 0xFF);
|
||||
return machineId % 1024; // 确保在0-1023范围内
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到合适的网络接口,使用本机IP地址
|
||||
InetAddress localHost = InetAddress.getLocalHost();
|
||||
byte[] address = localHost.getAddress();
|
||||
if (address.length >= 4) {
|
||||
// 使用IP地址的后两个字节生成机器ID
|
||||
long machineId = ((long) (address[address.length - 2] & 0xFF) << 8)
|
||||
| (long) (address[address.length - 1] & 0xFF);
|
||||
return machineId % 1024;
|
||||
}
|
||||
|
||||
throw new RuntimeException("无法获取网络接口信息生成机器ID");
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.emotionmuseum.common.config;
|
||||
|
||||
import com.emotionmuseum.common.interceptor.UserContextInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC 配置类
|
||||
* 注册拦截器和其他Web相关配置
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final UserContextInterceptor userContextInterceptor;
|
||||
|
||||
/**
|
||||
* 注册拦截器
|
||||
*/
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(userContextInterceptor)
|
||||
.addPathPatterns("/**") // 拦截所有请求
|
||||
.excludePathPatterns(
|
||||
// 排除静态资源
|
||||
"/static/**",
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/images/**",
|
||||
"/favicon.ico",
|
||||
|
||||
// 排除Swagger相关
|
||||
"/swagger-ui/**",
|
||||
"/swagger-resources/**",
|
||||
"/v2/api-docs",
|
||||
"/v3/api-docs",
|
||||
"/doc.html",
|
||||
|
||||
// 排除健康检查
|
||||
"/actuator/**",
|
||||
"/health",
|
||||
|
||||
// 排除错误页面
|
||||
"/error"
|
||||
)
|
||||
.order(1); // 设置拦截器执行顺序
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.emotionmuseum.common.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 分页查询基类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "分页查询参数")
|
||||
public class PageQuery {
|
||||
|
||||
/**
|
||||
* 页码
|
||||
*/
|
||||
@Schema(description = "页码", example = "1")
|
||||
@NotNull(message = "页码不能为空")
|
||||
@Min(value = 1, message = "页码最小为1")
|
||||
private Integer pageNum = 1;
|
||||
|
||||
/**
|
||||
* 每页大小
|
||||
*/
|
||||
@Schema(description = "每页大小", example = "10")
|
||||
@NotNull(message = "每页大小不能为空")
|
||||
@Min(value = 1, message = "每页大小最小为1")
|
||||
@Max(value = 100, message = "每页大小最大为100")
|
||||
private Integer pageSize = 10;
|
||||
|
||||
/**
|
||||
* 排序字段
|
||||
*/
|
||||
@Schema(description = "排序字段", example = "create_time")
|
||||
private String orderBy;
|
||||
|
||||
/**
|
||||
* 排序方向
|
||||
*/
|
||||
@Schema(description = "排序方向", example = "desc")
|
||||
private String orderDirection = "desc";
|
||||
|
||||
/**
|
||||
* 搜索关键词
|
||||
*/
|
||||
@Schema(description = "搜索关键词")
|
||||
private String keyword;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.emotionmuseum.common.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 基础实体类
|
||||
* 包含所有表的公共字段:create_by, create_time, update_by, update_time, is_deleted,
|
||||
* remarks
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键ID - 使用雪花算法生成的字符串ID
|
||||
* 避免前端JavaScript精度丢失问题
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.INPUT)
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 创建人ID
|
||||
*/
|
||||
@TableField(value = "create_by", fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(value = "create_time", fill = FieldFill.INSERT)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新人ID
|
||||
*/
|
||||
@TableField(value = "update_by", fill = FieldFill.INSERT_UPDATE)
|
||||
private String updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/**
|
||||
* 是否删除:0-未删除,1-已删除
|
||||
*/
|
||||
@TableLogic(value = "0", delval = "1")
|
||||
@TableField(value = "is_deleted", fill = FieldFill.INSERT)
|
||||
private Integer isDeleted;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
@TableField(value = "remarks")
|
||||
private String remarks;
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package com.emotionmuseum.common.handler;
|
||||
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import com.emotionmuseum.common.util.SnowflakeIdGenerator;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 自动填充处理器
|
||||
* 自动填充公共字段:id, create_by, create_time, update_by, update_time
|
||||
* 支持雪花算法自动生成主键ID
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class EmotionMetaObjectHandler implements MetaObjectHandler {
|
||||
|
||||
/**
|
||||
* 雪花算法ID生成器
|
||||
*/
|
||||
@Autowired
|
||||
private SnowflakeIdGenerator snowflakeIdGenerator;
|
||||
|
||||
/**
|
||||
* 插入时自动填充
|
||||
*/
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String currentUserId = getCurrentUserId();
|
||||
|
||||
// 填充主键ID(如果为空)
|
||||
fillPrimaryKey(metaObject);
|
||||
|
||||
// 填充创建时间
|
||||
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
|
||||
|
||||
// 填充更新时间
|
||||
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
|
||||
|
||||
// 填充创建人ID
|
||||
if (currentUserId != null) {
|
||||
this.strictInsertFill(metaObject, "createBy", String.class, currentUserId);
|
||||
}
|
||||
|
||||
// 填充更新人ID
|
||||
if (currentUserId != null) {
|
||||
this.strictInsertFill(metaObject, "updateBy", String.class, currentUserId);
|
||||
}
|
||||
|
||||
// 填充逻辑删除字段默认值
|
||||
this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
|
||||
|
||||
log.debug("插入时自动填充完成: createTime={}, updateTime={}, createBy={}, updateBy={}",
|
||||
now, now, currentUserId, currentUserId);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 自动填充失败不应该影响业务逻辑
|
||||
log.warn("插入时自动填充失败,但不影响业务逻辑: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新时自动填充
|
||||
*/
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String currentUserId = getCurrentUserId();
|
||||
|
||||
// 填充更新时间
|
||||
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, now);
|
||||
|
||||
// 填充更新人ID
|
||||
if (currentUserId != null) {
|
||||
this.strictUpdateFill(metaObject, "updateBy", String.class, currentUserId);
|
||||
}
|
||||
|
||||
log.debug("更新时自动填充完成: updateTime={}, updateBy={}", now, currentUserId);
|
||||
|
||||
} catch (Exception e) {
|
||||
// 自动填充失败不应该影响业务逻辑
|
||||
log.warn("更新时自动填充失败,但不影响业务逻辑: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充主键ID
|
||||
* 如果主键ID为空,则使用雪花算法生成
|
||||
*
|
||||
* @param metaObject 元对象
|
||||
*/
|
||||
private void fillPrimaryKey(MetaObject metaObject) {
|
||||
try {
|
||||
// 检查是否有id字段
|
||||
if (metaObject.hasSetter("id")) {
|
||||
Object idValue = metaObject.getValue("id");
|
||||
// 如果ID为空,则生成新的ID
|
||||
if (idValue == null || (idValue instanceof String && ((String) idValue).isEmpty())) {
|
||||
String newId = snowflakeIdGenerator.nextIdAsString();
|
||||
this.strictInsertFill(metaObject, "id", String.class, newId);
|
||||
log.debug("自动生成主键ID: {}", newId);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("主键ID自动填充失败,但不影响业务逻辑: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
* 优先级:
|
||||
* 1. 从ThreadLocal获取(如果有用户上下文)
|
||||
* 2. 从Spring Security获取(如果有认证信息)
|
||||
* 3. 返回系统默认值
|
||||
*
|
||||
* @return 当前用户ID,如果获取失败返回null
|
||||
*/
|
||||
private String getCurrentUserId() {
|
||||
try {
|
||||
// 1. 尝试从ThreadLocal获取用户ID(如果有用户上下文工具类)
|
||||
String userIdFromContext = getUserIdFromContext();
|
||||
if (userIdFromContext != null) {
|
||||
return userIdFromContext;
|
||||
}
|
||||
|
||||
// 2. 尝试从Spring Security获取用户ID
|
||||
String userIdFromSecurity = getUserIdFromSecurity();
|
||||
if (userIdFromSecurity != null) {
|
||||
return userIdFromSecurity;
|
||||
}
|
||||
|
||||
// 3. 返回系统默认值(用于系统操作或未登录用户)
|
||||
return "system";
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("获取当前用户ID失败: {}", e.getMessage());
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户上下文获取用户ID
|
||||
* 这里可以集成自定义的用户上下文工具类
|
||||
*
|
||||
* @return 用户ID或null
|
||||
*/
|
||||
private String getUserIdFromContext() {
|
||||
try {
|
||||
// TODO: 集成用户上下文工具类
|
||||
// 例如:return UserContextHolder.getCurrentUserId();
|
||||
|
||||
// 临时实现:从线程变量获取
|
||||
return UserContextHolder.getCurrentUserId();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("从用户上下文获取用户ID失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Spring Security获取用户ID
|
||||
*
|
||||
* @return 用户ID或null
|
||||
*/
|
||||
private String getUserIdFromSecurity() {
|
||||
try {
|
||||
// TODO: 集成Spring Security
|
||||
// Authentication authentication =
|
||||
// SecurityContextHolder.getContext().getAuthentication();
|
||||
// if (authentication != null && authentication.isAuthenticated()
|
||||
// && !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||
// UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
// return userDetails.getUsername(); // 或者从UserDetails中获取用户ID
|
||||
// }
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("从Spring Security获取用户ID失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户上下文持有者
|
||||
* 用于在当前线程中存储用户信息
|
||||
*/
|
||||
public static class UserContextHolder {
|
||||
|
||||
private static final ThreadLocal<String> USER_ID_HOLDER = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 设置当前用户ID
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
public static void setCurrentUserId(String userId) {
|
||||
USER_ID_HOLDER.set(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*
|
||||
* @return 用户ID
|
||||
*/
|
||||
public static String getCurrentUserId() {
|
||||
return USER_ID_HOLDER.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前用户ID
|
||||
*/
|
||||
public static void clear() {
|
||||
USER_ID_HOLDER.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.emotionmuseum.common.interceptor;
|
||||
|
||||
import com.emotionmuseum.common.handler.EmotionMetaObjectHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 用户上下文拦截器
|
||||
* 自动从请求头中提取用户信息并设置到ThreadLocal中
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class UserContextInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 用户ID请求头名称
|
||||
*/
|
||||
private static final String USER_ID_HEADER = "X-User-Id";
|
||||
|
||||
/**
|
||||
* 用户名请求头名称
|
||||
*/
|
||||
private static final String USERNAME_HEADER = "X-Username";
|
||||
|
||||
/**
|
||||
* Authorization请求头名称
|
||||
*/
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
try {
|
||||
// 1. 优先从自定义请求头获取用户ID
|
||||
String userId = request.getHeader(USER_ID_HEADER);
|
||||
|
||||
// 2. 如果没有自定义请求头,尝试从其他方式获取
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
userId = extractUserIdFromRequest(request);
|
||||
}
|
||||
|
||||
// 3. 如果仍然没有用户ID,使用默认值
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
userId = generateGuestUserId(request);
|
||||
}
|
||||
|
||||
// 4. 设置到ThreadLocal中
|
||||
EmotionMetaObjectHandler.UserContextHolder.setCurrentUserId(userId);
|
||||
|
||||
log.debug("设置用户上下文: userId={}, requestUri={}", userId, request.getRequestURI());
|
||||
|
||||
} catch (Exception e) {
|
||||
// 设置用户上下文失败不应该影响请求处理
|
||||
log.warn("设置用户上下文失败,使用默认值: {}", e.getMessage());
|
||||
EmotionMetaObjectHandler.UserContextHolder.setCurrentUserId("system");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
|
||||
throws Exception {
|
||||
try {
|
||||
// 清除ThreadLocal,避免内存泄漏
|
||||
EmotionMetaObjectHandler.UserContextHolder.clear();
|
||||
log.debug("清除用户上下文: requestUri={}", request.getRequestURI());
|
||||
} catch (Exception e) {
|
||||
log.warn("清除用户上下文失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取用户ID
|
||||
*
|
||||
* @param request HTTP请求
|
||||
* @return 用户ID或null
|
||||
*/
|
||||
private String extractUserIdFromRequest(HttpServletRequest request) {
|
||||
try {
|
||||
// 1. 从Authorization头解析JWT token(如果有)
|
||||
String authHeader = request.getHeader(AUTHORIZATION_HEADER);
|
||||
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
String userIdFromToken = extractUserIdFromToken(token);
|
||||
if (StringUtils.hasText(userIdFromToken)) {
|
||||
return userIdFromToken;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从用户名请求头获取
|
||||
String username = request.getHeader(USERNAME_HEADER);
|
||||
if (StringUtils.hasText(username)) {
|
||||
return username;
|
||||
}
|
||||
|
||||
// 3. 从请求参数获取
|
||||
String userIdParam = request.getParameter("userId");
|
||||
if (StringUtils.hasText(userIdParam)) {
|
||||
return userIdParam;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("从请求中提取用户ID失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JWT token中提取用户ID
|
||||
*
|
||||
* @param token JWT token
|
||||
* @return 用户ID或null
|
||||
*/
|
||||
private String extractUserIdFromToken(String token) {
|
||||
try {
|
||||
// TODO: 实现JWT token解析逻辑
|
||||
// 这里可以集成JWT工具类来解析token
|
||||
// 例如:
|
||||
// Claims claims = JwtUtils.parseToken(token);
|
||||
// return claims.getSubject();
|
||||
|
||||
log.debug("JWT token解析功能待实现");
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("解析JWT token失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为访客用户生成临时用户ID
|
||||
*
|
||||
* @param request HTTP请求
|
||||
* @return 临时用户ID
|
||||
*/
|
||||
private String generateGuestUserId(HttpServletRequest request) {
|
||||
try {
|
||||
// 1. 尝试从Session获取
|
||||
String sessionId = request.getSession(false) != null ? request.getSession().getId() : null;
|
||||
if (StringUtils.hasText(sessionId)) {
|
||||
return "guest_session_" + sessionId;
|
||||
}
|
||||
|
||||
// 2. 基于IP和User-Agent生成
|
||||
String clientIp = getClientIpAddress(request);
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
if (StringUtils.hasText(clientIp)) {
|
||||
String hash = String.valueOf((clientIp + userAgent).hashCode());
|
||||
return "guest_" + Math.abs(Integer.parseInt(hash));
|
||||
}
|
||||
|
||||
// 3. 使用时间戳作为最后的备选方案
|
||||
return "guest_" + System.currentTimeMillis();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("生成访客用户ID失败: {}", e.getMessage());
|
||||
return "guest_" + System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*
|
||||
* @param request HTTP请求
|
||||
* @return 客户端IP地址
|
||||
*/
|
||||
private String getClientIpAddress(HttpServletRequest request) {
|
||||
try {
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (StringUtils.hasText(xForwardedFor) && !"unknown".equalsIgnoreCase(xForwardedFor)) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
|
||||
String xRealIp = request.getHeader("X-Real-IP");
|
||||
if (StringUtils.hasText(xRealIp) && !"unknown".equalsIgnoreCase(xRealIp)) {
|
||||
return xRealIp;
|
||||
}
|
||||
|
||||
String proxyClientIp = request.getHeader("Proxy-Client-IP");
|
||||
if (StringUtils.hasText(proxyClientIp) && !"unknown".equalsIgnoreCase(proxyClientIp)) {
|
||||
return proxyClientIp;
|
||||
}
|
||||
|
||||
String wlProxyClientIp = request.getHeader("WL-Proxy-Client-IP");
|
||||
if (StringUtils.hasText(wlProxyClientIp) && !"unknown".equalsIgnoreCase(wlProxyClientIp)) {
|
||||
return wlProxyClientIp;
|
||||
}
|
||||
|
||||
return request.getRemoteAddr();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("获取客户端IP地址失败: {}", e.getMessage());
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.emotionmuseum.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 统一响应结果
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class Result<T> implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 响应码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 响应消息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
private T data;
|
||||
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
private Long timestamp;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
private String requestId;
|
||||
|
||||
public Result() {
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public Result(Integer code, String message) {
|
||||
this();
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Result(Integer code, String message, T data) {
|
||||
this(code, message);
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
*/
|
||||
public static <T> Result<T> success() {
|
||||
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应(带数据)
|
||||
*/
|
||||
public static <T> Result<T> success(T data) {
|
||||
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应(自定义消息)
|
||||
*/
|
||||
public static <T> Result<T> success(String message, T data) {
|
||||
return new Result<>(ResultCode.SUCCESS.getCode(), message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应
|
||||
*/
|
||||
public static <T> Result<T> error() {
|
||||
return new Result<>(ResultCode.INTERNAL_SERVER_ERROR.getCode(), ResultCode.INTERNAL_SERVER_ERROR.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应(自定义消息)
|
||||
*/
|
||||
public static <T> Result<T> error(String message) {
|
||||
return new Result<>(ResultCode.INTERNAL_SERVER_ERROR.getCode(), message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应(自定义码和消息)
|
||||
*/
|
||||
public static <T> Result<T> error(Integer code, String message) {
|
||||
return new Result<>(code, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应(结果码枚举)
|
||||
*/
|
||||
public static <T> Result<T> error(ResultCode resultCode) {
|
||||
return new Result<>(resultCode.getCode(), resultCode.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应(结果码枚举 + 数据)
|
||||
*/
|
||||
public static <T> Result<T> error(ResultCode resultCode, T data) {
|
||||
return new Result<>(resultCode.getCode(), resultCode.getMessage(), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否成功
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return ResultCode.SUCCESS.getCode().equals(this.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置请求ID
|
||||
*/
|
||||
public Result<T> requestId(String requestId) {
|
||||
this.requestId = requestId;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.emotionmuseum.common.result;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 响应状态码枚举
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ResultCode {
|
||||
|
||||
// ========== 通用状态码 ==========
|
||||
SUCCESS(200, "操作成功"),
|
||||
BAD_REQUEST(400, "请求参数错误"),
|
||||
UNAUTHORIZED(401, "未授权"),
|
||||
FORBIDDEN(403, "禁止访问"),
|
||||
NOT_FOUND(404, "资源不存在"),
|
||||
METHOD_NOT_ALLOWED(405, "请求方法不允许"),
|
||||
INTERNAL_SERVER_ERROR(500, "服务器内部错误"),
|
||||
SERVICE_UNAVAILABLE(503, "服务不可用"),
|
||||
|
||||
// ========== 业务状态码 1000-1999 ==========
|
||||
BUSINESS_ERROR(1000, "业务处理失败"),
|
||||
PARAM_VALIDATION_ERROR(1001, "参数校验失败"),
|
||||
DATA_NOT_FOUND(1002, "数据不存在"),
|
||||
DATA_ALREADY_EXISTS(1003, "数据已存在"),
|
||||
OPERATION_NOT_ALLOWED(1004, "操作不被允许"),
|
||||
|
||||
// ========== 用户相关 2000-2099 ==========
|
||||
USER_NOT_FOUND(2000, "用户不存在"),
|
||||
USER_ALREADY_EXISTS(2001, "用户已存在"),
|
||||
USER_DISABLED(2002, "用户已被禁用"),
|
||||
USER_NOT_VERIFIED(2003, "用户未验证"),
|
||||
INVALID_CREDENTIALS(2004, "用户名或密码错误"),
|
||||
PASSWORD_TOO_WEAK(2005, "密码强度不够"),
|
||||
PHONE_ALREADY_EXISTS(2006, "手机号已存在"),
|
||||
EMAIL_ALREADY_EXISTS(2007, "邮箱已存在"),
|
||||
ACCOUNT_ALREADY_EXISTS(2008, "账号已存在"),
|
||||
|
||||
// ========== 认证相关 2100-2199 ==========
|
||||
TOKEN_INVALID(2100, "Token无效"),
|
||||
TOKEN_EXPIRED(2101, "Token已过期"),
|
||||
TOKEN_MISSING(2102, "Token缺失"),
|
||||
REFRESH_TOKEN_INVALID(2103, "刷新Token无效"),
|
||||
LOGIN_REQUIRED(2104, "请先登录"),
|
||||
PERMISSION_DENIED(2105, "权限不足"),
|
||||
CAPTCHA_ERROR(2106, "验证码错误"),
|
||||
CAPTCHA_EXPIRED(2107, "验证码已过期"),
|
||||
|
||||
// ========== AI对话相关 2200-2299 ==========
|
||||
AI_SERVICE_ERROR(2200, "AI服务异常"),
|
||||
CONVERSATION_NOT_FOUND(2201, "对话不存在"),
|
||||
MESSAGE_SEND_FAILED(2202, "消息发送失败"),
|
||||
EMOTION_ANALYSIS_FAILED(2203, "情绪分析失败"),
|
||||
AI_RESPONSE_TIMEOUT(2204, "AI响应超时"),
|
||||
|
||||
// ========== 情绪记录相关 2300-2399 ==========
|
||||
EMOTION_RECORD_NOT_FOUND(2300, "情绪记录不存在"),
|
||||
EMOTION_TYPE_INVALID(2301, "情绪类型无效"),
|
||||
EMOTION_INTENSITY_INVALID(2302, "情绪强度无效"),
|
||||
EMOTION_DATE_INVALID(2303, "情绪日期无效"),
|
||||
|
||||
// ========== 成长课题相关 2400-2499 ==========
|
||||
TOPIC_NOT_FOUND(2400, "课题不存在"),
|
||||
TOPIC_NOT_UNLOCKED(2401, "课题未解锁"),
|
||||
TOPIC_ALREADY_COMPLETED(2402, "课题已完成"),
|
||||
INTERACTION_NOT_FOUND(2403, "互动记录不存在"),
|
||||
TOPIC_CATEGORY_INVALID(2404, "课题分类无效"),
|
||||
|
||||
// ========== 地图探索相关 2500-2599 ==========
|
||||
LOCATION_NOT_FOUND(2500, "地点不存在"),
|
||||
LOCATION_ALREADY_EXISTS(2501, "地点已存在"),
|
||||
COORDINATE_INVALID(2502, "坐标无效"),
|
||||
POST_NOT_FOUND(2503, "帖子不存在"),
|
||||
COMMENT_NOT_FOUND(2504, "评论不存在"),
|
||||
|
||||
// ========== 成就奖励相关 2600-2699 ==========
|
||||
ACHIEVEMENT_NOT_FOUND(2600, "成就不存在"),
|
||||
ACHIEVEMENT_ALREADY_UNLOCKED(2601, "成就已解锁"),
|
||||
REWARD_NOT_FOUND(2602, "奖励不存在"),
|
||||
INSUFFICIENT_POINTS(2603, "积分不足"),
|
||||
REWARD_ALREADY_CLAIMED(2604, "奖励已领取"),
|
||||
|
||||
// ========== 统计分析相关 2700-2799 ==========
|
||||
STATS_CALCULATION_ERROR(2700, "统计计算错误"),
|
||||
REPORT_GENERATION_FAILED(2701, "报告生成失败"),
|
||||
DATA_EXPORT_FAILED(2702, "数据导出失败"),
|
||||
|
||||
// ========== 文件上传相关 2800-2899 ==========
|
||||
FILE_UPLOAD_FAILED(2800, "文件上传失败"),
|
||||
FILE_TYPE_NOT_SUPPORTED(2801, "文件类型不支持"),
|
||||
FILE_SIZE_EXCEEDED(2802, "文件大小超限"),
|
||||
FILE_NOT_FOUND(2803, "文件不存在"),
|
||||
|
||||
// ========== 第三方服务相关 2900-2999 ==========
|
||||
THIRD_PARTY_SERVICE_ERROR(2900, "第三方服务异常"),
|
||||
SMS_SEND_FAILED(2901, "短信发送失败"),
|
||||
EMAIL_SEND_FAILED(2902, "邮件发送失败"),
|
||||
MAP_SERVICE_ERROR(2903, "地图服务异常"),
|
||||
PAYMENT_SERVICE_ERROR(2904, "支付服务异常"),
|
||||
|
||||
// ========== 系统相关 9000-9999 ==========
|
||||
SYSTEM_MAINTENANCE(9000, "系统维护中"),
|
||||
RATE_LIMIT_EXCEEDED(9001, "请求频率超限"),
|
||||
DATABASE_ERROR(9002, "数据库异常"),
|
||||
CACHE_ERROR(9003, "缓存异常"),
|
||||
MQ_ERROR(9004, "消息队列异常"),
|
||||
CONFIG_ERROR(9005, "配置错误");
|
||||
|
||||
/**
|
||||
* 状态码
|
||||
*/
|
||||
private final Integer code;
|
||||
|
||||
/**
|
||||
* 状态消息
|
||||
*/
|
||||
private final String message;
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package com.emotionmuseum.common.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HTTP工具类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HttpUtil {
|
||||
|
||||
@Autowired
|
||||
@Qualifier("restTemplate")
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("longRestTemplate")
|
||||
private RestTemplate longRestTemplate;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("fastRestTemplate")
|
||||
private RestTemplate fastRestTemplate;
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
public <T> T get(String url, Class<T> responseType) {
|
||||
return get(url, null, responseType);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求(带请求头)
|
||||
*/
|
||||
public <T> T get(String url, HttpHeaders headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<T> response = restTemplate.exchange(url, HttpMethod.GET, entity, responseType);
|
||||
return response.getBody();
|
||||
} catch (RestClientException e) {
|
||||
log.error("GET请求失败: url={}, error={}", url, e.getMessage());
|
||||
throw new RuntimeException("HTTP GET请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
public <T> T post(String url, Object requestBody, Class<T> responseType) {
|
||||
return post(url, requestBody, null, responseType);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求(带请求头)
|
||||
*/
|
||||
public <T> T post(String url, Object requestBody, HttpHeaders headers, Class<T> responseType) {
|
||||
try {
|
||||
if (headers == null) {
|
||||
headers = new HttpHeaders();
|
||||
}
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
String jsonBody = requestBody instanceof String ?
|
||||
(String) requestBody : JSON.toJSONString(requestBody);
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);
|
||||
ResponseEntity<T> response = restTemplate.exchange(url, HttpMethod.POST, entity, responseType);
|
||||
return response.getBody();
|
||||
} catch (RestClientException e) {
|
||||
log.error("POST请求失败: url={}, body={}, error={}", url, requestBody, e.getMessage());
|
||||
throw new RuntimeException("HTTP POST请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
*/
|
||||
public <T> T put(String url, Object requestBody, Class<T> responseType) {
|
||||
return put(url, requestBody, null, responseType);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT请求(带请求头)
|
||||
*/
|
||||
public <T> T put(String url, Object requestBody, HttpHeaders headers, Class<T> responseType) {
|
||||
try {
|
||||
if (headers == null) {
|
||||
headers = new HttpHeaders();
|
||||
}
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
String jsonBody = requestBody instanceof String ?
|
||||
(String) requestBody : JSON.toJSONString(requestBody);
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);
|
||||
ResponseEntity<T> response = restTemplate.exchange(url, HttpMethod.PUT, entity, responseType);
|
||||
return response.getBody();
|
||||
} catch (RestClientException e) {
|
||||
log.error("PUT请求失败: url={}, body={}, error={}", url, requestBody, e.getMessage());
|
||||
throw new RuntimeException("HTTP PUT请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
public <T> T delete(String url, Class<T> responseType) {
|
||||
return delete(url, null, responseType);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求(带请求头)
|
||||
*/
|
||||
public <T> T delete(String url, HttpHeaders headers, Class<T> responseType) {
|
||||
try {
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<T> response = restTemplate.exchange(url, HttpMethod.DELETE, entity, responseType);
|
||||
return response.getBody();
|
||||
} catch (RestClientException e) {
|
||||
log.error("DELETE请求失败: url={}, error={}", url, e.getMessage());
|
||||
throw new RuntimeException("HTTP DELETE请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 长连接POST请求(用于AI接口)
|
||||
*/
|
||||
public <T> T longPost(String url, Object requestBody, HttpHeaders headers, Class<T> responseType) {
|
||||
try {
|
||||
if (headers == null) {
|
||||
headers = new HttpHeaders();
|
||||
}
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
String jsonBody = requestBody instanceof String ?
|
||||
(String) requestBody : JSON.toJSONString(requestBody);
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);
|
||||
ResponseEntity<T> response = longRestTemplate.exchange(url, HttpMethod.POST, entity, responseType);
|
||||
return response.getBody();
|
||||
} catch (RestClientException e) {
|
||||
log.error("长连接POST请求失败: url={}, body={}, error={}", url, requestBody, e.getMessage());
|
||||
throw new RuntimeException("HTTP长连接POST请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速POST请求(用于内部服务调用)
|
||||
*/
|
||||
public <T> T fastPost(String url, Object requestBody, HttpHeaders headers, Class<T> responseType) {
|
||||
try {
|
||||
if (headers == null) {
|
||||
headers = new HttpHeaders();
|
||||
}
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
String jsonBody = requestBody instanceof String ?
|
||||
(String) requestBody : JSON.toJSONString(requestBody);
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);
|
||||
ResponseEntity<T> response = fastRestTemplate.exchange(url, HttpMethod.POST, entity, responseType);
|
||||
return response.getBody();
|
||||
} catch (RestClientException e) {
|
||||
log.error("快速POST请求失败: url={}, body={}, error={}", url, requestBody, e.getMessage());
|
||||
throw new RuntimeException("HTTP快速POST请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带Authorization的请求头
|
||||
*/
|
||||
public static HttpHeaders createAuthHeaders(String token) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (StrUtil.isNotBlank(token)) {
|
||||
headers.set("Authorization", token.startsWith("Bearer ") ? token : "Bearer " + token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带自定义请求头的HttpHeaders
|
||||
*/
|
||||
public static HttpHeaders createHeaders(Map<String, String> headerMap) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (headerMap != null && !headerMap.isEmpty()) {
|
||||
headerMap.forEach(headers::set);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package com.emotionmuseum.common.util;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JWT工具类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
/**
|
||||
* JWT密钥
|
||||
*/
|
||||
@Value("${jwt.secret:emotion-museum-secret-key-2025}")
|
||||
private String secret;
|
||||
|
||||
/**
|
||||
* JWT过期时间(秒)
|
||||
*/
|
||||
@Value("${jwt.expiration:86400}")
|
||||
private Long expiration;
|
||||
|
||||
/**
|
||||
* 刷新Token过期时间(秒)
|
||||
*/
|
||||
@Value("${jwt.refresh-expiration:604800}")
|
||||
private Long refreshExpiration;
|
||||
|
||||
/**
|
||||
* 获取密钥
|
||||
*/
|
||||
private SecretKey getSecretKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成Token
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @return Token
|
||||
*/
|
||||
public String generateToken(String userId, String username) {
|
||||
return generateToken(userId, username, expiration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成刷新Token
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @return 刷新Token
|
||||
*/
|
||||
public String generateRefreshToken(String userId, String username) {
|
||||
return generateToken(userId, username, refreshExpiration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成Token
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param username 用户名
|
||||
* @param expiration 过期时间(秒)
|
||||
* @return Token
|
||||
*/
|
||||
private String generateToken(String userId, String username, Long expiration) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration * 1000);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.claim("username", username)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(getSecretKey(), SignatureAlgorithm.HS512)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Token中获取用户ID
|
||||
*
|
||||
* @param token Token
|
||||
* @return 用户ID
|
||||
*/
|
||||
public String getUserIdFromToken(String token) {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
return claims != null ? claims.getSubject() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Token中获取用户名
|
||||
*
|
||||
* @param token Token
|
||||
* @return 用户名
|
||||
*/
|
||||
public String getUsernameFromToken(String token) {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
return claims != null ? claims.get("username", String.class) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Token中获取过期时间
|
||||
*
|
||||
* @param token Token
|
||||
* @return 过期时间
|
||||
*/
|
||||
public Date getExpirationDateFromToken(String token) {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
return claims != null ? claims.getExpiration() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Token中获取Claims
|
||||
*
|
||||
* @param token Token
|
||||
* @return Claims
|
||||
*/
|
||||
private Claims getClaimsFromToken(String token) {
|
||||
try {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSecretKey())
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
} catch (Exception e) {
|
||||
log.warn("解析Token失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Token是否有效
|
||||
*
|
||||
* @param token Token
|
||||
* @return 是否有效
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
if (StrUtil.isBlank(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
if (claims == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
Date expiration = claims.getExpiration();
|
||||
return expiration != null && expiration.after(new Date());
|
||||
} catch (Exception e) {
|
||||
log.warn("Token验证失败: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Token是否过期
|
||||
*
|
||||
* @param token Token
|
||||
* @return 是否过期
|
||||
*/
|
||||
public boolean isTokenExpired(String token) {
|
||||
Date expiration = getExpirationDateFromToken(token);
|
||||
return expiration != null && expiration.before(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新Token
|
||||
*
|
||||
* @param token 原Token
|
||||
* @return 新Token
|
||||
*/
|
||||
public String refreshToken(String token) {
|
||||
try {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
if (claims == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String userId = claims.getSubject();
|
||||
String username = claims.get("username", String.class);
|
||||
|
||||
return generateToken(userId, username);
|
||||
} catch (Exception e) {
|
||||
log.warn("刷新Token失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头中提取Token
|
||||
*
|
||||
* @param authHeader Authorization头
|
||||
* @return Token
|
||||
*/
|
||||
public String extractTokenFromHeader(String authHeader) {
|
||||
if (StrUtil.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package com.emotionmuseum.common.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 雪花算法ID生成器
|
||||
* 生成64位长整型ID,转换为字符串避免前端精度丢失问题
|
||||
*
|
||||
* 雪花算法结构:
|
||||
* 1位符号位(固定为0) + 41位时间戳 + 10位机器ID + 12位序列号
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-13
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SnowflakeIdGenerator {
|
||||
|
||||
/**
|
||||
* 起始时间戳 (2024-01-01 00:00:00)
|
||||
*/
|
||||
private static final long START_TIMESTAMP = 1704067200000L;
|
||||
|
||||
/**
|
||||
* 机器ID位数
|
||||
*/
|
||||
private static final long MACHINE_ID_BITS = 10L;
|
||||
|
||||
/**
|
||||
* 序列号位数
|
||||
*/
|
||||
private static final long SEQUENCE_BITS = 12L;
|
||||
|
||||
/**
|
||||
* 机器ID最大值
|
||||
*/
|
||||
private static final long MAX_MACHINE_ID = ~(-1L << MACHINE_ID_BITS);
|
||||
|
||||
/**
|
||||
* 序列号最大值
|
||||
*/
|
||||
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
|
||||
|
||||
/**
|
||||
* 机器ID左移位数
|
||||
*/
|
||||
private static final long MACHINE_ID_SHIFT = SEQUENCE_BITS;
|
||||
|
||||
/**
|
||||
* 时间戳左移位数
|
||||
*/
|
||||
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + MACHINE_ID_BITS;
|
||||
|
||||
/**
|
||||
* 机器ID
|
||||
*/
|
||||
private final long machineId;
|
||||
|
||||
/**
|
||||
* 序列号
|
||||
*/
|
||||
private long sequence = 0L;
|
||||
|
||||
/**
|
||||
* 上次生成ID的时间戳
|
||||
*/
|
||||
private long lastTimestamp = -1L;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param machineId 机器ID (0-1023)
|
||||
*/
|
||||
public SnowflakeIdGenerator(long machineId) {
|
||||
if (machineId > MAX_MACHINE_ID || machineId < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("机器ID必须在0到%d之间", MAX_MACHINE_ID));
|
||||
}
|
||||
this.machineId = machineId;
|
||||
log.info("雪花算法ID生成器初始化完成,机器ID: {}", machineId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认构造函数,使用默认机器ID
|
||||
*/
|
||||
public SnowflakeIdGenerator() {
|
||||
// 使用当前时间戳的后10位作为默认机器ID
|
||||
this(System.currentTimeMillis() % (MAX_MACHINE_ID + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一个ID
|
||||
*
|
||||
* @return 生成的ID
|
||||
*/
|
||||
public synchronized long nextId() {
|
||||
long timestamp = getCurrentTimestamp();
|
||||
|
||||
// 如果当前时间小于上次ID生成的时间戳,说明系统时钟回退过,抛出异常
|
||||
if (timestamp < lastTimestamp) {
|
||||
throw new RuntimeException(
|
||||
String.format("系统时钟回退,拒绝生成ID。当前时间戳: %d, 上次时间戳: %d",
|
||||
timestamp, lastTimestamp));
|
||||
}
|
||||
|
||||
// 如果是同一时间戳,则在序列号上自增
|
||||
if (lastTimestamp == timestamp) {
|
||||
sequence = (sequence + 1) & MAX_SEQUENCE;
|
||||
// 如果序列号溢出,则等待下一个毫秒
|
||||
if (sequence == 0) {
|
||||
timestamp = getNextTimestamp(lastTimestamp);
|
||||
}
|
||||
} else {
|
||||
// 如果是新的时间戳,则序列号重置为0
|
||||
sequence = 0L;
|
||||
}
|
||||
|
||||
// 更新上次生成ID的时间戳
|
||||
lastTimestamp = timestamp;
|
||||
|
||||
// 移位并通过或运算拼到一起组成64位的ID
|
||||
return ((timestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
|
||||
| (machineId << MACHINE_ID_SHIFT)
|
||||
| sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字符串格式的ID
|
||||
*
|
||||
* @return 字符串格式的ID
|
||||
*/
|
||||
public String nextIdAsString() {
|
||||
return String.valueOf(nextId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间戳
|
||||
*
|
||||
* @return 当前时间戳
|
||||
*/
|
||||
private long getCurrentTimestamp() {
|
||||
return System.currentTimeMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一个时间戳
|
||||
*
|
||||
* @param lastTimestamp 上次时间戳
|
||||
* @return 下一个时间戳
|
||||
*/
|
||||
private long getNextTimestamp(long lastTimestamp) {
|
||||
long timestamp = getCurrentTimestamp();
|
||||
while (timestamp <= lastTimestamp) {
|
||||
timestamp = getCurrentTimestamp();
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析ID获取时间戳
|
||||
*
|
||||
* @param id 雪花算法生成的ID
|
||||
* @return 时间戳
|
||||
*/
|
||||
public long parseTimestamp(long id) {
|
||||
return (id >> TIMESTAMP_SHIFT) + START_TIMESTAMP;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析ID获取机器ID
|
||||
*
|
||||
* @param id 雪花算法生成的ID
|
||||
* @return 机器ID
|
||||
*/
|
||||
public long parseMachineId(long id) {
|
||||
return (id >> MACHINE_ID_SHIFT) & MAX_MACHINE_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析ID获取序列号
|
||||
*
|
||||
* @param id 雪花算法生成的ID
|
||||
* @return 序列号
|
||||
*/
|
||||
public long parseSequence(long id) {
|
||||
return id & MAX_SEQUENCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取机器ID
|
||||
*
|
||||
* @return 机器ID
|
||||
*/
|
||||
public long getMachineId() {
|
||||
return machineId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成ID
|
||||
*
|
||||
* @param count 生成数量
|
||||
* @return ID数组
|
||||
*/
|
||||
public long[] nextIds(int count) {
|
||||
if (count <= 0) {
|
||||
throw new IllegalArgumentException("生成数量必须大于0");
|
||||
}
|
||||
|
||||
long[] ids = new long[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
ids[i] = nextId();
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成字符串格式的ID
|
||||
*
|
||||
* @param count 生成数量
|
||||
* @return 字符串ID数组
|
||||
*/
|
||||
public String[] nextIdsAsString(int count) {
|
||||
if (count <= 0) {
|
||||
throw new IllegalArgumentException("生成数量必须大于0");
|
||||
}
|
||||
|
||||
String[] ids = new String[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
ids[i] = nextIdAsString();
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.emotionmuseum.common.util;
|
||||
|
||||
import com.emotionmuseum.common.handler.EmotionMetaObjectHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 用户上下文工具类
|
||||
* 提供手动设置和获取用户上下文的方法
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-12
|
||||
*/
|
||||
@Slf4j
|
||||
public class UserContextUtil {
|
||||
|
||||
/**
|
||||
* 设置当前用户ID
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
public static void setCurrentUserId(String userId) {
|
||||
if (StringUtils.hasText(userId)) {
|
||||
EmotionMetaObjectHandler.UserContextHolder.setCurrentUserId(userId);
|
||||
log.debug("手动设置用户上下文: userId={}", userId);
|
||||
} else {
|
||||
log.warn("尝试设置空的用户ID");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID
|
||||
*
|
||||
* @return 当前用户ID,如果没有则返回null
|
||||
*/
|
||||
public static String getCurrentUserId() {
|
||||
return EmotionMetaObjectHandler.UserContextHolder.getCurrentUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前用户上下文
|
||||
*/
|
||||
public static void clearCurrentUser() {
|
||||
EmotionMetaObjectHandler.UserContextHolder.clear();
|
||||
log.debug("手动清除用户上下文");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有当前用户
|
||||
*
|
||||
* @return 如果有当前用户返回true,否则返回false
|
||||
*/
|
||||
public static boolean hasCurrentUser() {
|
||||
return StringUtils.hasText(getCurrentUserId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户ID,如果没有则返回默认值
|
||||
*
|
||||
* @param defaultUserId 默认用户ID
|
||||
* @return 当前用户ID或默认值
|
||||
*/
|
||||
public static String getCurrentUserIdOrDefault(String defaultUserId) {
|
||||
String currentUserId = getCurrentUserId();
|
||||
return StringUtils.hasText(currentUserId) ? currentUserId : defaultUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定用户上下文中执行操作
|
||||
* 执行完成后会恢复原来的用户上下文
|
||||
*
|
||||
* @param userId 临时用户ID
|
||||
* @param runnable 要执行的操作
|
||||
*/
|
||||
public static void runWithUser(String userId, Runnable runnable) {
|
||||
String originalUserId = getCurrentUserId();
|
||||
try {
|
||||
setCurrentUserId(userId);
|
||||
runnable.run();
|
||||
} finally {
|
||||
if (originalUserId != null) {
|
||||
setCurrentUserId(originalUserId);
|
||||
} else {
|
||||
clearCurrentUser();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定用户上下文中执行操作并返回结果
|
||||
* 执行完成后会恢复原来的用户上下文
|
||||
*
|
||||
* @param userId 临时用户ID
|
||||
* @param supplier 要执行的操作
|
||||
* @param <T> 返回值类型
|
||||
* @return 操作结果
|
||||
*/
|
||||
public static <T> T runWithUser(String userId, java.util.function.Supplier<T> supplier) {
|
||||
String originalUserId = getCurrentUserId();
|
||||
try {
|
||||
setCurrentUserId(userId);
|
||||
return supplier.get();
|
||||
} finally {
|
||||
if (originalUserId != null) {
|
||||
setCurrentUserId(originalUserId);
|
||||
} else {
|
||||
clearCurrentUser();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为访客用户生成临时ID
|
||||
*
|
||||
* @return 访客用户ID
|
||||
*/
|
||||
public static String generateGuestUserId() {
|
||||
return "guest_" + System.currentTimeMillis() + "_" +
|
||||
Integer.toHexString((int)(Math.random() * 0x10000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户ID是否为访客用户
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 如果是访客用户返回true
|
||||
*/
|
||||
public static boolean isGuestUser(String userId) {
|
||||
return StringUtils.hasText(userId) && userId.startsWith("guest_");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户ID是否为系统用户
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 如果是系统用户返回true
|
||||
*/
|
||||
public static boolean isSystemUser(String userId) {
|
||||
return "system".equals(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户类型描述
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户类型描述
|
||||
*/
|
||||
public static String getUserTypeDescription(String userId) {
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return "未知用户";
|
||||
}
|
||||
|
||||
if (isSystemUser(userId)) {
|
||||
return "系统用户";
|
||||
}
|
||||
|
||||
if (isGuestUser(userId)) {
|
||||
return "访客用户";
|
||||
}
|
||||
|
||||
return "注册用户";
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package com.emotionmuseum.common.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 雪花算法ID生成器测试类
|
||||
*
|
||||
* @author emotion-museum
|
||||
* @since 2025-07-13
|
||||
*/
|
||||
public class SnowflakeIdGeneratorTest {
|
||||
|
||||
private SnowflakeIdGenerator generator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
generator = new SnowflakeIdGenerator(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNextId() {
|
||||
long id = generator.nextId();
|
||||
assertTrue(id > 0, "生成的ID应该大于0");
|
||||
|
||||
// 测试连续生成的ID不相同
|
||||
long id2 = generator.nextId();
|
||||
assertNotEquals(id, id2, "连续生成的ID应该不相同");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNextIdAsString() {
|
||||
String id = generator.nextIdAsString();
|
||||
assertNotNull(id, "生成的字符串ID不应该为null");
|
||||
assertFalse(id.isEmpty(), "生成的字符串ID不应该为空");
|
||||
|
||||
// 验证是数字字符串
|
||||
assertDoesNotThrow(() -> Long.parseLong(id), "生成的字符串应该是有效的数字");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUniqueIds() {
|
||||
Set<Long> ids = new HashSet<>();
|
||||
int count = 10000;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
long id = generator.nextId();
|
||||
assertTrue(ids.add(id), "生成的ID应该是唯一的: " + id);
|
||||
}
|
||||
|
||||
assertEquals(count, ids.size(), "应该生成指定数量的唯一ID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConcurrentGeneration() throws InterruptedException {
|
||||
int threadCount = 10;
|
||||
int idsPerThread = 1000;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch latch = new CountDownLatch(threadCount);
|
||||
Set<Long> allIds = new HashSet<>();
|
||||
AtomicInteger duplicateCount = new AtomicInteger(0);
|
||||
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
Set<Long> threadIds = new HashSet<>();
|
||||
for (int j = 0; j < idsPerThread; j++) {
|
||||
long id = generator.nextId();
|
||||
threadIds.add(id);
|
||||
}
|
||||
|
||||
synchronized (allIds) {
|
||||
for (Long id : threadIds) {
|
||||
if (!allIds.add(id)) {
|
||||
duplicateCount.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
latch.await();
|
||||
executor.shutdown();
|
||||
|
||||
assertEquals(0, duplicateCount.get(), "并发生成的ID不应该有重复");
|
||||
assertEquals(threadCount * idsPerThread, allIds.size(), "应该生成正确数量的唯一ID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseTimestamp() {
|
||||
long id = generator.nextId();
|
||||
long timestamp = generator.parseTimestamp(id);
|
||||
|
||||
// 时间戳应该在合理范围内(当前时间前后1分钟)
|
||||
long currentTime = System.currentTimeMillis();
|
||||
assertTrue(Math.abs(timestamp - currentTime) < 60000,
|
||||
"解析的时间戳应该接近当前时间");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseMachineId() {
|
||||
long id = generator.nextId();
|
||||
long machineId = generator.parseMachineId(id);
|
||||
|
||||
assertEquals(1L, machineId, "解析的机器ID应该等于设置的机器ID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseSequence() {
|
||||
long id = generator.nextId();
|
||||
long sequence = generator.parseSequence(id);
|
||||
|
||||
assertTrue(sequence >= 0 && sequence < 4096,
|
||||
"解析的序列号应该在0-4095范围内");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBatchGeneration() {
|
||||
int count = 100;
|
||||
long[] ids = generator.nextIds(count);
|
||||
|
||||
assertEquals(count, ids.length, "应该生成指定数量的ID");
|
||||
|
||||
// 验证所有ID都是唯一的
|
||||
Set<Long> uniqueIds = new HashSet<>();
|
||||
for (long id : ids) {
|
||||
assertTrue(uniqueIds.add(id), "批量生成的ID应该是唯一的");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBatchGenerationAsString() {
|
||||
int count = 100;
|
||||
String[] ids = generator.nextIdsAsString(count);
|
||||
|
||||
assertEquals(count, ids.length, "应该生成指定数量的字符串ID");
|
||||
|
||||
// 验证所有ID都是唯一的且为有效数字
|
||||
Set<String> uniqueIds = new HashSet<>();
|
||||
for (String id : ids) {
|
||||
assertNotNull(id, "字符串ID不应该为null");
|
||||
assertFalse(id.isEmpty(), "字符串ID不应该为空");
|
||||
assertDoesNotThrow(() -> Long.parseLong(id), "字符串ID应该是有效数字");
|
||||
assertTrue(uniqueIds.add(id), "批量生成的字符串ID应该是唯一的");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidMachineId() {
|
||||
// 测试无效的机器ID
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
new SnowflakeIdGenerator(-1L);
|
||||
}, "负数机器ID应该抛出异常");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
new SnowflakeIdGenerator(1024L);
|
||||
}, "超出范围的机器ID应该抛出异常");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidBatchCount() {
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
generator.nextIds(0);
|
||||
}, "批量生成数量为0应该抛出异常");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> {
|
||||
generator.nextIds(-1);
|
||||
}, "批量生成数量为负数应该抛出异常");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# 探索服务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-explore ./emotion-explore
|
||||
|
||||
# 安装Maven
|
||||
RUN apk add --no-cache maven
|
||||
|
||||
# 构建应用
|
||||
RUN mvn clean package -DskipTests -pl emotion-explore -am
|
||||
|
||||
# 创建运行用户
|
||||
RUN addgroup -g 1000 emotion && \
|
||||
adduser -D -s /bin/sh -u 1000 -G emotion emotion
|
||||
|
||||
# 复制jar文件
|
||||
RUN cp emotion-explore/target/emotion-explore-*.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:19005/actuator/health || exit 1
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 19005
|
||||
|
||||
# 启动命令
|
||||
ENTRYPOINT ["java", "-jar", \
|
||||
"-Xms512m", "-Xmx1024m", \
|
||||
"-Djava.security.egd=file:/dev/./urandom", \
|
||||
"-Dspring.profiles.active=local", \
|
||||
"app.jar"]
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
#!/bin/bash
|
||||
|
||||
# emotion-explore 单独部署脚本
|
||||
# 作者: emotion-museum
|
||||
# 日期: 2025-07-18
|
||||
|
||||
set -e
|
||||
|
||||
# 配置变量
|
||||
SERVICE_NAME="emotion-explore"
|
||||
SERVICE_PORT="19005"
|
||||
REMOTE_HOST="'root@47.111.10.27'"
|
||||
REMOTE_BUILD_DIR="/data/builds"
|
||||
REMOTE_DOCKER_COMPOSE_DIR="/data/docker"
|
||||
PROFILE="test"
|
||||
PROJECT_NAME="emotion-museum"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# 检查远程服务器连接
|
||||
check_remote_connection() {
|
||||
log_info "检查远程服务器连接..."
|
||||
if ssh -o ConnectTimeout=10 'root@47.111.10.27' "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_success "远程服务器连接正常"
|
||||
else
|
||||
log_error "无法连接到远程服务器 'root@47.111.10.27'"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 构建服务
|
||||
build_service() {
|
||||
log_info "构建服务: $SERVICE_NAME"
|
||||
|
||||
# 构建父项目依赖
|
||||
cd ..
|
||||
mvn clean install -DskipTests -q
|
||||
cd emotion-explore
|
||||
|
||||
# 构建当前服务
|
||||
if mvn clean package -DskipTests -Ptest -q; then
|
||||
log_success "服务 $SERVICE_NAME 构建成功"
|
||||
else
|
||||
log_error "服务 $SERVICE_NAME 构建失败"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile() {
|
||||
log_info "创建Dockerfile: $SERVICE_NAME"
|
||||
|
||||
ssh 'root@47.111.10.27' "cat > $REMOTE_DOCKER_COMPOSE_DIR/Dockerfile.${SERVICE_NAME} << 'EOF'
|
||||
# 使用阿里云镜像源的OpenJDK
|
||||
# 使用Java 17 Alpine镜像
|
||||
FROM openjdk:17-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装必要的工具 (Alpine Linux使用apk)
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY ${SERVICE_NAME}-1.0.0.jar app.jar
|
||||
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/\$TZ /etc/localtime && echo \$TZ > /etc/timezone
|
||||
|
||||
EXPOSE ${SERVICE_PORT}
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \\
|
||||
CMD curl -f http://localhost:${SERVICE_PORT}/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT [\"java\", \"-Djava.security.egd=file:/dev/./urandom\", \"-Xms512m\", \"-Xmx1024m\", \"-jar\", \"app.jar\"]
|
||||
EOF"
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
deploy_service() {
|
||||
log_info "开始部署服务: $SERVICE_NAME"
|
||||
|
||||
# 检查jar包
|
||||
local jar_file="target/${SERVICE_NAME}-1.0.0.jar"
|
||||
if [ ! -f "$jar_file" ]; then
|
||||
log_error "JAR包不存在: $jar_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建远程目录
|
||||
ssh 'root@47.111.10.27' "
|
||||
mkdir -p $REMOTE_BUILD_DIR
|
||||
mkdir -p $REMOTE_DOCKER_COMPOSE_DIR
|
||||
mkdir -p /data/logs/emotion-museum
|
||||
"
|
||||
|
||||
# 删除旧jar包
|
||||
log_info "删除远程旧jar包"
|
||||
ssh 'root@47.111.10.27' "rm -f $REMOTE_BUILD_DIR/${SERVICE_NAME}-*.jar"
|
||||
|
||||
# 上传新jar包
|
||||
log_info "上传jar包"
|
||||
if scp "$jar_file" 'root@47.111.10.27':$REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar; then
|
||||
log_success "jar包上传成功"
|
||||
else
|
||||
log_error "jar包上传失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建Dockerfile
|
||||
create_dockerfile
|
||||
|
||||
# 停止旧容器
|
||||
log_info "停止旧容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker stop ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rm ${SERVICE_NAME} 2>/dev/null || true
|
||||
docker rmi ${PROJECT_NAME}/${SERVICE_NAME}:latest 2>/dev/null || true
|
||||
"
|
||||
|
||||
# 创建Docker网络
|
||||
ssh 'root@47.111.10.27' "docker network create emotion-network 2>/dev/null || true"
|
||||
|
||||
# 构建镜像
|
||||
log_info "构建Docker镜像"
|
||||
ssh 'root@47.111.10.27' "
|
||||
cd $REMOTE_DOCKER_COMPOSE_DIR
|
||||
# 复制jar包到Docker构建目录
|
||||
cp $REMOTE_BUILD_DIR/${SERVICE_NAME}-1.0.0.jar $REMOTE_DOCKER_COMPOSE_DIR/
|
||||
|
||||
# 构建镜像 docker build -t ${PROJECT_NAME}/${SERVICE_NAME}:latest -f Dockerfile.${SERVICE_NAME} .
|
||||
|
||||
# 清理临时文件
|
||||
rm -f ${SERVICE_NAME}-1.0.0.jar "
|
||||
|
||||
# 启动容器
|
||||
log_info "启动新容器"
|
||||
ssh 'root@47.111.10.27' "
|
||||
docker run -d \\
|
||||
--name ${SERVICE_NAME} \\
|
||||
--network emotion-network \\
|
||||
-p ${SERVICE_PORT}:${SERVICE_PORT} \\
|
||||
-v /data/logs/emotion-museum:/app/logs \\
|
||||
-e SPRING_PROFILES_ACTIVE=${PROFILE} \\
|
||||
-e MYSQL_HOST=47.111.10.27 \\
|
||||
-e MYSQL_PORT=3306 \\
|
||||
-e MYSQL_DATABASE=emotion_museum \\
|
||||
-e MYSQL_USERNAME=root \\
|
||||
-e MYSQL_PASSWORD='EmotionMuseum2025*#' \\
|
||||
-e REDIS_HOST=47.111.10.27 \\
|
||||
-e REDIS_PORT=6379 \\
|
||||
-e REDIS_PASSWORD= \\
|
||||
-e REDIS_DATABASE=0 \\
|
||||
-e NACOS_SERVER_ADDR=47.111.10.27:8848 \\
|
||||
-e NACOS_USERNAME=nacos \\
|
||||
-e NACOS_PASSWORD='Peanut2817*#' \\
|
||||
--restart unless-stopped \\
|
||||
${PROJECT_NAME}/${SERVICE_NAME}:latest
|
||||
"
|
||||
|
||||
# 等待启动
|
||||
log_info "等待服务启动..."
|
||||
sleep 15
|
||||
|
||||
# 检查状态
|
||||
if ssh 'root@47.111.10.27' "docker ps | grep ${SERVICE_NAME}" > /dev/null; then
|
||||
log_success "服务启动成功"
|
||||
|
||||
# 显示日志
|
||||
log_info "服务日志 最后20行:"
|
||||
ssh 'root@47.111.10.27' "docker logs --tail 20 ${SERVICE_NAME}"
|
||||
|
||||
# 健康检查
|
||||
log_info "执行健康检查..."
|
||||
sleep 10
|
||||
if ssh 'root@47.111.10.27' "curl -f -s http://localhost:${SERVICE_PORT}/actuator/health" > /dev/null 2>&1; then
|
||||
log_success "健康检查通过"
|
||||
else
|
||||
log_warning "健康检查失败,服务可能仍在启动中"
|
||||
fi
|
||||
else
|
||||
log_error "服务启动失败"
|
||||
ssh 'root@47.111.10.27' "docker logs ${SERVICE_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
log_info "开始部署 $SERVICE_NAME 服务"
|
||||
log_info "目标服务器: $REMOTE_HOST"
|
||||
log_info "服务端口: $SERVICE_PORT"
|
||||
log_info "部署环境: $PROFILE"
|
||||
|
||||
check_remote_connection
|
||||
build_service
|
||||
deploy_service
|
||||
|
||||
log_success "$SERVICE_NAME 服务部署完成!"
|
||||
log_info "访问地址: http://47.111.10.27:$SERVICE_PORT"
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user