demo
This commit is contained in:
lxy
2025-08-25 16:01:26 +08:00
commit 5a671c4233
93 changed files with 26892 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

19
.mvn/wrapper/maven-wrapper.properties vendored Normal file
View File

@ -0,0 +1,19 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
wrapperVersion=3.3.2
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip

458
api.md Normal file
View File

@ -0,0 +1,458 @@
# 海角AI - API接口文档
## 用户认证模块
### 用户登录
**功能描述**: 用户通过邮箱和密码进行登录认证登录成功后返回token和用户基本信息
**入参**:
- `email`: string - 用户邮箱地址
- `password`: string - 用户密码
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.token`: string - 访问令牌
- `body.user`: object - 用户信息对象
- `body.user.id`: int - 用户ID
- `body.user.nickname`: string - 用户昵称
- `body.user.email`: string - 用户邮箱
- `body.user.avatar`: string - 用户头像URL
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/auth/login
**请求方式**: POST
---
### 用户注册
**功能描述**: 新用户注册,创建账户并返回用户信息
**入参**:
- `email`: string - 用户邮箱地址
- `password`: string - 用户密码
- `nickname`: string - 用户昵称
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.user_id`: int - 新创建的用户ID
- `body.token`: string - 访问令牌
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/auth/register
**请求方式**: POST
---
### 用户登出
**功能描述**: 用户登出使当前token失效
**入参**: 无
**返回参数**:
- `error`: int - 错误码0表示成功
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/auth/logout
**请求方式**: POST
---
### 获取用户信息
**功能描述**: 获取当前登录用户的详细信息
**入参**: 无通过Authorization Header传递token
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.user_id`: int - 用户ID
- `body.nickname`: string - 用户昵称
- `body.email`: string - 用户邮箱
- `body.avatar`: string - 用户头像URL
- `body.created_at`: int - 注册时间戳
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/user/info
**请求方式**: GET
---
## 聊天模块
### 创建会话
**功能描述**: 创建新的聊天会话,用于组织对话
**入参**:
- `title`: string - 会话标题
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.session_id`: string - 会话ID
- `body.title`: string - 会话标题
- `body.created_at`: int - 创建时间戳
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/chat/session
**请求方式**: POST
---
### 发送消息
**功能描述**: 向指定会话发送消息AI会根据消息内容和配置参数生成回复
**入参**:
- `session_id`: string - 会话ID
- `content`: string - 消息内容
- `model`: string - 使用的AI模型默认为"Hunyuan"
- `deep_thinking`: bool - 是否启用深度思考模式
- `web_search`: bool - 是否启用联网搜索
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.user_message_id`: string - 用户消息ID
- `body.ai_response`: object - AI回复对象
- `body.ai_response.id`: string - AI消息ID
- `body.ai_response.content`: string - AI回复内容
- `body.ai_response.timestamp`: int - 回复时间戳
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/chat/send
**请求方式**: POST
---
### 获取会话列表
**功能描述**: 获取用户的所有聊天会话列表,支持分页和关键词搜索
**入参**:
- `page`: int - 页码默认1
- `page_size`: int - 每页数量默认20
- `keyword`: string - 搜索关键词(可选)
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.sessions`: array - 会话列表
- `body.sessions[].id`: string - 会话ID
- `body.sessions[].title`: string - 会话标题
- `body.sessions[].message_count`: int - 消息数量
- `body.sessions[].last_message_time`: int - 最后消息时间戳
- `body.total`: int - 总数量
- `body.has_more`: bool - 是否还有更多
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/chat/sessions
**请求方式**: GET
---
### 获取会话消息
**功能描述**: 获取指定会话的消息历史记录,支持分页
**入参**:
- `session_id`: string - 会话ID
- `page`: int - 页码默认1
- `page_size`: int - 每页数量默认50
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.messages`: array - 消息列表
- `body.messages[].id`: string - 消息ID
- `body.messages[].role`: string - 消息角色user/assistant
- `body.messages[].content`: string - 消息内容
- `body.messages[].timestamp`: int - 消息时间戳
- `body.messages[].is_favorited`: bool - 是否已收藏
- `body.total`: int - 总数量
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/chat/messages
**请求方式**: GET
---
### 删除会话
**功能描述**: 删除指定的聊天会话及其所有消息
**入参**: 无会话ID通过URL路径传递
**返回参数**:
- `error`: int - 错误码0表示成功
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/chat/session/{session_id}
**请求方式**: DELETE
---
### 清空会话
**功能描述**: 清空指定会话的所有消息,但保留会话本身
**入参**: 无会话ID通过URL路径传递
**返回参数**:
- `error`: int - 错误码0表示成功
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/chat/session/{session_id}/clear
**请求方式**: POST
---
## 收藏模块
### 收藏消息
**功能描述**: 将指定消息添加到收藏列表
**入参**:
- `message_id`: string - 消息ID
- `session_id`: string - 会话ID
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.favorite_id`: string - 收藏记录ID
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/favorites/add
**请求方式**: POST
---
### 取消收藏
**功能描述**: 从收藏列表中移除指定消息
**入参**:
- `message_id`: string - 消息ID
**返回参数**:
- `error`: int - 错误码0表示成功
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/favorites/remove
**请求方式**: DELETE
---
### 获取收藏列表
**功能描述**: 获取用户的所有收藏消息列表,支持分页
**入参**:
- `page`: int - 页码默认1
- `page_size`: int - 每页数量默认20
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.favorites`: array - 收藏列表
- `body.favorites[].id`: string - 收藏记录ID
- `body.favorites[].message_id`: string - 消息ID
- `body.favorites[].content`: string - 消息内容
- `body.favorites[].session_title`: string - 所属会话标题
- `body.favorites[].created_at`: int - 收藏时间戳
- `body.total`: int - 总数量
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/favorites/list
**请求方式**: GET
---
## 文件上传模块
### 上传文件
**功能描述**: 上传文件支持txt、pdf、md等格式用于AI分析和处理
**入参**:
- `file`: file - 上传的文件
- `session_id`: string - 关联的会话ID
**返回参数**:
- `error`: int - 错误码0表示成功
- `body`: object - 响应数据
- `body.file_id`: string - 文件ID
- `body.filename`: string - 文件名
- `body.file_size`: int - 文件大小(字节)
- `body.file_type`: string - 文件类型
- `body.upload_time`: int - 上传时间戳
- `message`: string - 响应消息
- `success`: bool - 是否成功
**url地址**: /api/files/upload
**请求方式**: POST
---
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 0 | 成功 |
| 1001 | 参数错误 |
| 1002 | 用户不存在 |
| 1003 | 密码错误 |
| 1004 | 邮箱已存在 |
| 1005 | Token无效或已过期 |
| 2001 | 会话不存在 |
| 2002 | 消息发送失败 |
| 2003 | 文件上传失败 |
| 2004 | 文件格式不支持 |
| 2005 | 文件大小超出限制 |
| 3001 | 收藏失败 |
| 3002 | 收藏记录不存在 |
| 5000 | 服务器内部错误 |
| 5001 | AI服务不可用 |
| 5002 | 网络搜索服务不可用 |
---
## 通用响应格式
所有API接口都采用统一的响应格式
```json
{
"error": 0,
"body": {
// 具体的响应数据
},
"message": "操作成功",
"success": true
}
```
## 认证说明
除了登录和注册接口外其他接口都需要在请求头中携带Authorization
```
Authorization: Bearer <token>
```
## 基础信息
- **基础URL**: http://localhost:8080
- **超时时间**: 10秒
- **默认Content-Type**: application/json
- **支持的AI模型**: Hunyuan、基础模型、增强模型
## 请求示例
### 登录请求示例
```javascript
// 请求
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password123"
}
// 响应
{
"error": 0,
"body": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"user": {
"id": 1,
"nickname": "用户昵称",
"email": "user@example.com",
"avatar": "https://example.com/avatar.jpg"
}
},
"message": "登录成功",
"success": true
}
```
### 发送消息请求示例
```javascript
// 请求
POST /api/chat/send
Authorization: Bearer <token>
Content-Type: application/json
{
"session_id": "session_123",
"content": "你好请帮我解释一下Vue3的组合式API",
"model": "Hunyuan",
"deep_thinking": true,
"web_search": false
}
// 响应
{
"error": 0,
"body": {
"user_message_id": "msg_456",
"ai_response": {
"id": "msg_789",
"content": "Vue3的组合式API是一种新的组织组件逻辑的方式...",
"timestamp": 1692345678
}
},
"message": "消息发送成功",
"success": true
}
```
### 文件上传请求示例
```javascript
// 请求
POST /api/files/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
FormData:
- file: [选择的文件]
- session_id: "session_123"
// 响应
{
"error": 0,
"body": {
"file_id": "file_456",
"filename": "document.pdf",
"file_size": 1024000,
"file_type": "application/pdf",
"upload_time": 1692345678
},
"message": "文件上传成功",
"success": true
}
```

View File

@ -0,0 +1,40 @@
# TASK001 - 项目基础架构搭建
## 任务信息
- **任务编号**: TASK001
- **任务名称**: 项目基础架构搭建
- **版本**: V1.0
- **状态**: 完成
## 任务描述
搭建海角AI后端项目的基础架构包括Spring Boot项目初始化、Maven依赖配置、项目目录结构规划、基础配置文件设置和开发环境配置。
## 验收标准清单
- [x] Spring Boot 3.x项目创建完成
- [x] Maven依赖配置完整包括web、jpa、security、jwt、redis、mysql等核心依赖
- [x] 项目目录结构规范包含controller、service、repository、entity、dto、config等包结构
- [x] application.yml配置文件完成基础配置数据库、服务器端口、日志等
- [x] 开发环境profiles配置dev、test、prod
- [x] 项目能正常启动并访问健康检查接口
- [x] 集成Swagger/OpenAPI文档工具
- [x] 配置跨域处理
- [x] 添加统一的日志配置
## 注意事项
1. 使用Spring Boot 3.x最新稳定版本确保与Java 17+兼容性
2. Maven依赖版本要保持一致性避免版本冲突
3. 包命名遵循com.lxy.hsend的约定
4. 配置文件中敏感信息使用环境变量或配置中心
5. 预留AI服务调用的HTTP客户端配置如RestTemplate或WebClient
6. 考虑文件上传大小限制配置
7. 设置合理的数据库连接池配置
8. 添加actuator健康检查端点
9. 配置时区为Asia/Shanghai
10. 预留Redis配置用于会话管理和缓存
## 相关文件
- pom.xml
- src/main/resources/application.yml
- src/main/resources/application-dev.yml
- src/main/java/com/lxy/hsend/config/
- src/main/java/com/lxy/hsend/HsEndApplication.java

View File

@ -0,0 +1,41 @@
# TASK002 - 数据库设计和初始化
## 任务信息
- **任务编号**: TASK002
- **任务名称**: 数据库设计和初始化
- **版本**: V1.0
- **状态**: 完成
## 任务描述
根据API文档设计数据库表结构创建用户、会话、消息、收藏、文件等核心业务表建立表之间的关联关系编写数据库初始化脚本和JPA Entity实体类。
## 验收标准清单
- [x] 完成数据库表结构设计,包括:用户表、会话表、消息表、收藏表、文件表
- [x] 建立表之间的外键关系和索引
- [x] 编写SQL初始化脚本schema.sql和data.sql - 使用Flyway迁移脚本V1__Create_initial_tables.sql
- [x] 创建对应的JPA Entity实体类 - User、Session、Message、Favorite、File
- [x] 配置JPA/Hibernate相关属性 - application.yml中已配置
- [x] 添加数据库版本管理工具Flyway或Liquibase - 已添加Flyway依赖和配置
- [x] 创建Repository接口 - UserRepository、SessionRepository、MessageRepository、FavoriteRepository、FileRepository
- [x] 验证数据库连接和表创建成功 - 测试通过,数据库连接正常
- [x] 编写基础的数据库操作测试 - DatabaseConnectionTest测试基本CRUD操作成功
## 注意事项
1. 用户表需包含id、email、password_hash、nickname、avatar_url、created_at、updated_at等字段
2. 会话表需包含session_idUUID、user_id、title、message_count、last_message_time、created_at等字段
3. 消息表需包含message_idUUID、session_id、roleuser/assistant、content、timestamp、model_used、is_favorited等字段
4. 收藏表需包含favorite_id、user_id、message_id、session_id、created_at等字段
5. 文件表需包含file_id、user_id、session_id、filename、file_path、file_size、file_type、upload_time等字段
6. 所有主键使用UUID或雪花ID避免数据泄露
7. 敏感字段需要考虑加密存储如密码使用BCrypt
8. 添加软删除字段deleted_at用于数据恢复
9. 时间字段统一使用BIGINT存储时间戳
10. 考虑分表分库的预留设计如用户ID取模
11. 为高频查询字段添加索引如session_id、user_id、timestamp等
12. 设置合理的字符集utf8mb4支持emoji
## 相关文件
- src/main/resources/db/migration/
- src/main/java/com/lxy/hsend/entity/
- src/main/java/com/lxy/hsend/repository/
- src/main/resources/application.yml数据库配置

View File

@ -0,0 +1,43 @@
# TASK003 - 基础配置和公共组件
## 任务信息
- **任务编号**: TASK003
- **任务名称**: 基础配置和公共组件
- **版本**: V1.0
- **状态**: ✅ 完成
## 任务描述
实现项目的基础配置和公共组件包括统一响应格式、全局异常处理、请求参数验证、跨域配置、JWT工具类、Redis配置等公共功能模块。
## 验收标准清单
- [x] 创建统一响应格式类ApiResponse - 完成,支持成功/失败响应和各种错误类型
- [x] 实现全局异常处理器GlobalExceptionHandler - 完成,覆盖所有异常类型和错误码
- [x] 配置参数验证注解和自定义验证器 - 完成,包括@ValidEmail@ValidPassword等
- [x] 实现JWT工具类生成、验证、解析token - 完成支持token生成、验证、黑名单机制
- [x] 配置Redis连接和序列化器 - 完成,包括缓存管理器和序列化配置
- [x] 创建通用工具类(时间处理、字符串工具、加密工具等) - 完成TimeUtil、StringUtil、CryptoUtil
- [x] 实现自定义注解(如@RequireAuth用于权限验证 - 完成@RequireAuth@RequestLog@RateLimit等
- [x] 配置Web MVC相关设置 - 完成CORS、静态资源、拦截器配置
- [x] 创建常量类和枚举类 - 完成Constants、ErrorCode枚举
- [x] 实现分页工具类 - 完成PageUtil支持分页查询和响应转换
## 注意事项
1. ApiResponse格式需严格按照API文档中的统一响应格式error、body、message、success字段
2. 异常处理需要覆盖所有API文档中定义的错误码1001-5002
3. JWT工具类需要支持token生成、验证、刷新和黑名单机制
4. 参数验证需要支持邮箱格式、密码强度、文件大小等自定义规则
5. Redis配置需要支持session存储和缓存功能
6. 工具类方法需要线程安全
7. 时间处理统一使用时间戳格式
8. 密码加密使用BCrypt算法
9. 文件上传需要支持类型验证和大小限制
10. 异常信息不应暴露敏感的系统内部信息
11. 添加请求日志记录功能
12. 配置合理的超时时间和重试机制
## 相关文件
- src/main/java/com/lxy/hsend/common/
- src/main/java/com/lxy/hsend/config/
- src/main/java/com/lxy/hsend/util/
- src/main/java/com/lxy/hsend/exception/
- src/main/java/com/lxy/hsend/annotation/

View File

@ -0,0 +1,37 @@
# TASK004 - 用户认证模块
## 任务信息
- **任务编号**: TASK004
- **任务名称**: 用户认证模块
- **版本**: V1.0
- **状态**: ✅ 完成
## 任务描述
实现用户认证相关功能包括用户注册、登录、登出、获取用户信息等接口集成JWT认证机制实现用户数据的增删改查操作。
## 验收标准清单
- [x] 实现用户注册接口(/api/auth/register - 完成,支持邮箱、密码、昵称注册
- [x] 实现用户登录接口(/api/auth/login - 完成返回JWT token和用户信息
- [x] 实现用户登出接口(/api/auth/logout - 完成token加入黑名单机制
- [x] 实现获取用户信息接口(/api/user/info - 完成,支持当前用户和公开用户信息
- [x] 集成JWT认证拦截器 - 完成自动验证token和设置用户上下文
- [x] 实现密码加密和验证 - 完成使用BCrypt加密
- [x] 实现邮箱格式验证和重复性检查 - 完成,注册时检查邮箱唯一性
- [x] 创建UserService、UserController和相关DTO - 完成,完整的服务层和控制层
- [x] 实现token黑名单机制 - 完成使用Redis存储黑名单token
- [x] 添加登录失败次数限制 - 完成Redis记录失败次数15分钟锁定
## 注意事项
1. 注册时需要验证邮箱格式和唯一性
2. 登录成功后返回JWT token和用户基本信息不能返回密码
9. 邮箱字段需要转换为小写存储
12. 注册时可以设置默认头像
13. 需要记录用户最后登录时间
14. 实现用户状态管理(正常、锁定、删除)
## 相关文件
- src/main/java/com/lxy/hsend/controller/AuthController.java
- src/main/java/com/lxy/hsend/controller/UserController.java
- src/main/java/com/lxy/hsend/service/UserService.java
- src/main/java/com/lxy/hsend/dto/auth/
- src/main/java/com/lxy/hsend/config/JwtAuthenticationFilter.java

View File

@ -0,0 +1,41 @@
# TASK005 - 聊天会话管理模块
## 任务信息
- **任务编号**: TASK005
- **任务名称**: 聊天会话管理模块
- **版本**: V1.0
- **状态**: ✅ 完成
## 任务描述
实现聊天会话的管理功能,包括创建会话、获取会话列表、获取会话消息、删除会话、清空会话等操作,支持会话的搜索和分页功能。
## 验收标准清单
- [ ] 实现创建会话接口(/api/chat/session
- [ ] 实现获取会话列表接口(/api/chat/sessions
- [ ] 实现获取会话消息接口(/api/chat/messages
- [ ] 实现删除会话接口(/api/chat/session/{session_id}
- [ ] 实现清空会话接口(/api/chat/session/{session_id}/clear
- [ ] 支持会话关键词搜索功能
- [ ] 实现会话列表分页查询
- [ ] 创建SessionService、SessionController和相关DTO
- [ ] 实现会话权限验证(用户只能操作自己的会话)
- [ ] 添加会话统计信息更新机制
## 注意事项
1. 会话ID使用UUID格式确保全局唯一性
2. 创建会话时需要验证用户登录状态和标题合法性
3. 获取会话列表需要按最后消息时间倒序排列
4. 关键词搜索需要支持标题和消息内容的模糊匹配
5. 分页参数需要设置合理的默认值和最大值限制
6. 删除会话时需要级联删除相关的消息和收藏记录
7. 清空会话只删除消息,保留会话本身
8. 用户只能访问和操作自己创建的会话
10. 需要实时更新会话的消息数量和最后消息时间
12. 考虑会话的软删除机制,便于数据恢复
14. 会话列表需要返回消息数量和最后消息时间
## 相关文件
- src/main/java/com/lxy/hsend/controller/SessionController.java
- src/main/java/com/lxy/hsend/service/SessionService.java
- src/main/java/com/lxy/hsend/dto/session/
- src/main/java/com/lxy/hsend/repository/SessionRepository.java

View File

@ -0,0 +1,37 @@
# TASK006 - 消息处理模块
## 任务信息
- **任务编号**: TASK006
- **任务名称**: 消息处理模块
- **版本**: V1.0
- **状态**: ✅ 完成
## 任务描述
实现消息发送和处理功能包括用户消息存储、AI响应生成、消息历史查询等核心聊天功能为后续AI服务集成做好接口预留。
## 验收标准清单
- [x] 实现发送消息接口(/api/chat/send
- [x] 实现消息存储和查询功能
- [x] 创建消息实体和相关DTO类
- [x] 实现消息分页查询
- [x] 添加消息内容验证和过滤
- [x] 实现消息角色区分user/assistant
- [x] 创建MessageService、MessageController
- [x] 预留AI服务调用接口
- [x] 实现消息时间戳记录
- [x] 添加消息状态管理
## 注意事项
1. 消息ID使用UUID格式确保唯一性
8. 支持深度思考和联网搜索的参数传递
9. 消息发送失败时需要返回明确的错误信息
10. 考虑消息的编辑和删除功能预留
12. 需要统计每个会话的消息数量
14. 预留消息附件(图片、文件)的关联字段
15. 实现消息的软删除机制
## 相关文件
- src/main/java/com/lxy/hsend/controller/MessageController.java
- src/main/java/com/lxy/hsend/service/MessageService.java
- src/main/java/com/lxy/hsend/dto/message/
- src/main/java/com/lxy/hsend/repository/MessageRepository.java

View File

@ -0,0 +1,45 @@
# TASK007 - 收藏功能模块
## 任务信息
- **任务编号**: TASK007
- **任务名称**: 收藏功能模块
- **版本**: V1.0
- **状态**: ✅ 完成
## 任务描述
实现用户消息收藏功能,包括添加收藏、取消收藏、获取收藏列表等操作,支持收藏消息的搜索和分页查询功能。
## 验收标准清单
- [x] 实现收藏消息接口(/api/favorites/add
- [x] 实现取消收藏接口(/api/favorites/remove
- [x] 实现获取收藏列表接口(/api/favorites/list
- [x] 创建收藏实体和相关DTO类
- [x] 实现收藏状态查询和更新
- [x] 支持收藏列表分页功能
- [x] 添加收藏重复性检查
- [x] 创建FavoriteService、FavoriteController
- [x] 实现收藏权限验证
- [x] 添加收藏数量统计功能
## 注意事项
1. 收藏记录需要关联用户ID、消息ID和会话ID
2. 同一条消息不能被同一用户重复收藏
3. 取消收藏时需要验证收藏记录的存在性
4. 用户只能操作自己的收藏记录
5. 收藏列表需要显示消息内容和所属会话标题
6. 收藏列表按收藏时间倒序排列
7. 删除消息时需要同步删除相关收藏记录
8. 删除会话时需要同步删除相关收藏记录
9. 收藏ID使用UUID格式确保唯一性
10. 需要在消息查询时返回收藏状态
11. 收藏列表支持按会话标题搜索
12. 设置用户收藏数量的合理上限
13. 收藏操作需要添加操作日志
14. 考虑收藏的分类和标签功能预留
15. 收藏列表需要显示消息的完整上下文信息
## 相关文件
- src/main/java/com/lxy/hsend/controller/FavoriteController.java
- src/main/java/com/lxy/hsend/service/FavoriteService.java
- src/main/java/com/lxy/hsend/dto/favorite/
- src/main/java/com/lxy/hsend/repository/FavoriteRepository.java

View File

@ -0,0 +1,47 @@
# TASK008 - 文件上传模块
## 任务信息
- **任务编号**: TASK008
- **任务名称**: 文件上传模块
- **版本**: V1.0
- **状态**: 跳过该模块,暂时进行不开发
## 任务描述
实现文件上传功能支持txt、pdf、md等格式文件的上传、存储和管理为AI文档分析功能提供基础支持。
## 验收标准清单
- [ ] 实现文件上传接口(/api/files/upload
- [ ] 支持多种文件格式验证txt、pdf、md、docx等
- [ ] 实现文件大小限制和安全检查
- [ ] 创建文件存储和管理功能
- [ ] 实现文件元数据记录
- [ ] 添加文件访问权限控制
- [ ] 创建FileService、FileController和相关DTO
- [ ] 实现文件内容解析预处理
- [ ] 添加文件删除和清理功能
- [ ] 实现文件上传进度反馈
## 注意事项
1. 文件大小限制在10MB以内防止服务器资源耗尽
2. 支持的文件类型:.txt、.pdf、.md、.docx、.doc、.xlsx、.xls
3. 文件名需要重命名使用UUID+原扩展名避免冲突
4. 上传的文件需要进行病毒扫描和安全检查
5. 文件存储路径需要按日期分层组织(如/files/2024/01/01/
6. 记录文件的原始名称、存储路径、大小、类型等元数据
7. 用户只能访问自己上传的文件
8. 文件上传需要关联到具体的会话
9. 实现文件的软删除,定期清理无效文件
10. 添加文件上传失败的重试机制
11. 支持断点续传功能(可选)
12. 文件存储考虑使用OSS或本地文件系统
13. 需要生成文件的访问URL
14. 添加文件下载接口用于预览
15. 实现文件内容的文本提取功能
16. 设置用户文件数量和总大小限制
## 相关文件
- src/main/java/com/lxy/hsend/controller/FileController.java
- src/main/java/com/lxy/hsend/service/FileService.java
- src/main/java/com/lxy/hsend/dto/file/
- src/main/java/com/lxy/hsend/repository/FileRepository.java
- src/main/java/com/lxy/hsend/util/FileUtil.java

View File

@ -0,0 +1,46 @@
# TASK009 - AI服务集成
## 任务信息
- **任务编号**: TASK009
- **任务名称**: AI服务集成
- **版本**: V1.0
- **状态**: 计划中
## 任务描述
集成外部AI服务接口实现多模型支持、深度思考模式、联网搜索等AI增强功能为用户提供智能对话服务。
## 验收标准清单
- [ ] 集成腾讯混元AI模型接口
- [ ] 实现基础模型和增强模型切换
- [ ] 集成深度思考模式功能
- [ ] 实现联网搜索功能
- [ ] 创建AI服务抽象层和实现类
- [ ] 实现AI响应的流式处理
- [ ] 添加AI服务的错误处理和重试机制
- [ ] 实现会话上下文管理
- [ ] 添加AI调用的监控和日志
- [ ] 实现AI服务的负载均衡
## 注意事项
1. AI模型支持Hunyuan混元、基础模型、增强模型
2. 深度思考模式需要调用更强的推理模型,响应时间较长
3. 联网搜索需要整合搜索引擎API获取实时信息
4. AI服务调用需要添加超时控制如30秒
5. 实现AI响应的流式输出提升用户体验
6. 会话上下文需要控制长度避免token超限
7. AI服务异常时需要返回友好的错误提示
8. 记录AI调用的token消耗和成本统计
9. 实现AI服务的API密钥管理和轮换
10. 添加AI内容的安全过滤和合规检查
11. 支持AI模型的动态配置和热切换
12. 实现AI响应的缓存机制优化性能
13. 添加AI服务的限流和降级策略
14. 文件上传后需要提取内容传递给AI分析
15. 实现AI对话的记忆管理和遗忘机制
16. 支持自定义AI系统提示词配置
## 相关文件
- src/main/java/com/lxy/hsend/service/ai/
- src/main/java/com/lxy/hsend/config/AIConfig.java
- src/main/java/com/lxy/hsend/dto/ai/
- src/main/java/com/lxy/hsend/integration/

View File

@ -0,0 +1,46 @@
# TASK010 - 安全性和权限控制
## 任务信息
- **任务编号**: TASK010
- **任务名称**: 安全性和权限控制
- **版本**: V1.0
- **状态**: 计划中
## 任务描述
加强系统安全性,实现接口鉴权、参数验证、防攻击措施、数据加密等安全功能,确保用户数据安全和系统稳定运行。
## 验收标准清单
- [ ] 实现JWT token验证和刷新机制
- [ ] 添加接口访问频率限制Rate Limiting
- [ ] 实现参数校验和SQL注入防护
- [ ] 添加XSS攻击防护
- [ ] 实现CSRF防护机制
- [ ] 配置HTTPS和安全响应头
- [ ] 实现敏感数据加密存储
- [ ] 添加操作日志和审计功能
- [ ] 实现IP白名单和黑名单机制
- [ ] 配置跨域访问控制
## 注意事项
1. 所有需要登录的接口都必须验证JWT token
2. 实现token的自动续期机制提升用户体验
3. 对高频接口添加访问频率限制如每分钟最多100次请求
4. 用户输入必须进行严格的参数验证和过滤
5. 密码、密钥等敏感信息需要加密存储
6. 记录所有重要操作的日志,包括用户行为和系统异常
7. 生产环境必须启用HTTPS禁用HTTP
8. 配置安全响应头X-Frame-Options、X-XSS-Protection等
9. 实现账户锁定机制,防止暴力破解
10. 文件上传需要检查文件类型和内容安全性
11. AI生成内容需要进行敏感信息过滤
12. 数据库查询需要使用预编译语句防止SQL注入
13. 会话管理需要防止会话劫持
14. 实现接口访问的白名单机制
15. 添加异常访问的监控和告警
16. 配置合理的CORS策略
## 相关文件
- src/main/java/com/lxy/hsend/security/
- src/main/java/com/lxy/hsend/config/SecurityConfig.java
- src/main/java/com/lxy/hsend/filter/
- src/main/java/com/lxy/hsend/aspect/SecurityAspect.java

View File

@ -0,0 +1,46 @@
# TASK011 - 性能优化和缓存
## 任务信息
- **任务编号**: TASK011
- **任务名称**: 性能优化和缓存
- **版本**: V1.0
- **状态**: 计划中
## 任务描述
实现系统性能优化和缓存机制包括Redis缓存、数据库优化、接口性能监控等功能提升系统响应速度和并发处理能力。
## 验收标准清单
- [ ] 配置Redis缓存和缓存策略
- [ ] 实现热点数据缓存(用户信息、会话列表等)
- [ ] 优化数据库查询和索引
- [ ] 实现分页查询优化
- [ ] 添加接口性能监控和统计
- [ ] 实现数据库连接池优化
- [ ] 配置异步处理机制
- [ ] 实现AI响应结果缓存
- [ ] 添加静态资源CDN配置
- [ ] 实现数据预加载机制
## 注意事项
1. 用户信息、会话列表等热点数据使用Redis缓存设置合理的过期时间
2. 缓存更新策略采用Cache-Aside模式确保数据一致性
3. 数据库查询添加必要的索引特别是用户ID、会话ID等关联字段
4. 分页查询使用游标分页替代传统offset分页提升大数据量查询性能
5. 接口响应时间监控,记录慢查询和异常情况
6. 数据库连接池配置合理的最大连接数和超时时间
7. AI服务调用使用异步处理避免阻塞主线程
8. 相同问题的AI响应可以缓存一定时间减少重复调用
9. 静态资源头像、文件使用CDN加速访问
10. 系统启动时预加载常用配置和数据字典
11. 实现数据库读写分离,提升查询性能
12. 使用连接池管理Redis连接避免连接泄露
13. 实现缓存雪崩和缓存穿透的防护机制
14. 大文件上传使用分片上传技术
15. 配置JVM参数优化内存使用
16. 实现系统资源使用率监控
## 相关文件
- src/main/java/com/lxy/hsend/config/CacheConfig.java
- src/main/java/com/lxy/hsend/config/RedisConfig.java
- src/main/java/com/lxy/hsend/aspect/PerformanceAspect.java
- src/main/java/com/lxy/hsend/service/cache/

View File

@ -0,0 +1,48 @@
# TASK012 - 集成测试和文档完善
## 任务信息
- **任务编号**: TASK012
- **任务名称**: 集成测试和文档完善
- **版本**: V1.0
- **状态**: 计划中
## 任务描述
编写完整的单元测试和集成测试完善API文档和部署文档确保系统功能正确性和可维护性为项目上线做好准备。
## 验收标准清单
- [ ] 编写单元测试覆盖所有Service层方法
- [ ] 编写集成测试覆盖所有API接口
- [ ] 实现数据库测试和Mock数据准备
- [ ] 完善Swagger API文档注解
- [ ] 编写部署文档和运维手册
- [ ] 实现健康检查和监控接口
- [ ] 添加系统性能基准测试
- [ ] 创建Docker镜像和部署脚本
- [ ] 编写用户使用手册
- [ ] 实现自动化测试流程
## 注意事项
1. 单元测试需要达到80%以上的代码覆盖率
2. 集成测试需要覆盖所有API接口的正常和异常场景
3. 使用TestContainers进行数据库集成测试
4. Mock外部依赖AI服务、文件存储等进行隔离测试
5. Swagger文档需要包含完整的参数说明和示例
6. 部署文档需要包含环境要求、配置说明、启动步骤
7. 健康检查接口需要检查数据库、Redis、外部服务连通性
8. 性能测试需要模拟并发用户和大数据量场景
9. Docker镜像需要优化大小和启动速度
10. 测试数据需要支持自动清理和重置
11. 文档需要包含常见问题解答和故障排查指南
12. 实现CI/CD流水线自动化构建和部署
13. 添加代码质量检查和安全扫描
14. 准备压力测试报告和性能调优建议
15. 编写数据库备份和恢复方案
16. 制定系统监控和告警策略
## 相关文件
- src/test/java/com/lxy/hsend/
- docker/Dockerfile
- docs/deployment.md
- docs/api-guide.md
- docs/troubleshooting.md
- scripts/deploy.sh

149
doc/任务总览.md Normal file
View File

@ -0,0 +1,149 @@
# 海角AI后端开发任务总览
## 项目概述
海角AI是一个智能对话产品采用经典的两栏式布局为用户提供AI聊天、会话管理、消息收藏、文件上传等功能。本文档详细规划了后端开发的所有任务。
## 任务列表概览
| 任务编号 | 任务名称 | 版本 | 状态 | 优先级 | 预估工期 |
|---------|---------|------|------|-------|---------|
| TASK001 | 项目基础架构搭建 | V1.0 | 计划中 | 高 | 2天 |
| TASK002 | 数据库设计和初始化 | V1.0 | 计划中 | 高 | 3天 |
| TASK003 | 基础配置和公共组件 | V1.0 | 计划中 | 高 | 3天 |
| TASK004 | 用户认证模块 | V1.0 | 计划中 | 高 | 4天 |
| TASK005 | 聊天会话管理模块 | V1.0 | 计划中 | 中 | 3天 |
| TASK006 | 消息处理模块 | V1.0 | 计划中 | 高 | 4天 |
| TASK007 | 收藏功能模块 | V1.0 | 计划中 | 中 | 2天 |
| TASK008 | 文件上传模块 | V1.0 | 计划中 | 中 | 3天 |
| TASK009 | AI服务集成 | V1.0 | 计划中 | 高 | 5天 |
| TASK010 | 安全性和权限控制 | V1.0 | 计划中 | 高 | 3天 |
| TASK011 | 性能优化和缓存 | V1.0 | 计划中 | 中 | 3天 |
| TASK012 | 集成测试和文档完善 | V1.0 | 计划中 | 中 | 4天 |
**总计预估工期39天**
## 开发阶段规划
### 第一阶段基础建设7-8天
- TASK001项目基础架构搭建
- TASK002数据库设计和初始化
- TASK003基础配置和公共组件
### 第二阶段核心功能11-13天
- TASK004用户认证模块
- TASK005聊天会话管理模块
- TASK006消息处理模块
- TASK009AI服务集成
### 第三阶段扩展功能8-10天
- TASK007收藏功能模块
- TASK008文件上传模块
- TASK010安全性和权限控制
### 第四阶段优化完善7天
- TASK011性能优化和缓存
- TASK012集成测试和文档完善
## 技术栈说明
### 后端框架
- **Spring Boot 3.x**:主要开发框架
- **Spring Security**:安全认证框架
- **Spring Data JPA**:数据持久层框架
- **MySQL 8.0**:主数据库
- **Redis**:缓存和会话存储
### 开发工具
- **Maven**:项目构建和依赖管理
- **JWT**:用户认证令牌
- **Swagger/OpenAPI**API文档生成
- **Docker**:容器化部署
- **Flyway**:数据库版本管理
### 外部服务
- **腾讯混元AI**AI对话服务
- **文件存储**本地存储或OSS
- **搜索引擎API**:联网搜索功能
## API接口概览
### 用户认证模块4个接口
- POST /api/auth/register - 用户注册
- POST /api/auth/login - 用户登录
- POST /api/auth/logout - 用户登出
- GET /api/user/info - 获取用户信息
### 聊天模块6个接口
- POST /api/chat/session - 创建会话
- POST /api/chat/send - 发送消息
- GET /api/chat/sessions - 获取会话列表
- GET /api/chat/messages - 获取会话消息
- DELETE /api/chat/session/{session_id} - 删除会话
- POST /api/chat/session/{session_id}/clear - 清空会话
### 收藏模块3个接口
- POST /api/favorites/add - 收藏消息
- DELETE /api/favorites/remove - 取消收藏
- GET /api/favorites/list - 获取收藏列表
### 文件上传模块1个接口
- POST /api/files/upload - 上传文件
## 数据库表设计概览
### 核心业务表
- **users**:用户表
- **sessions**:会话表
- **messages**:消息表
- **favorites**:收藏表
- **files**:文件表
### 系统表
- **operation_logs**:操作日志表
- **system_configs**:系统配置表
## 部署环境要求
### 硬件要求
- **CPU**4核心以上
- **内存**8GB以上
- **存储**50GB以上SSD
### 软件要求
- **Java 17+**
- **MySQL 8.0+**
- **Redis 6.0+**
- **Nginx**(反向代理)
## 风险控制
### 技术风险
1. **AI服务依赖**外部AI服务不稳定需要实现降级和重试机制
2. **并发处理**:高并发情况下的性能瓶颈,需要优化缓存和数据库
3. **数据安全**:用户数据和会话内容的安全保护
### 业务风险
1. **内容安全**AI生成内容的合规性检查
2. **用户体验**:响应时间和系统稳定性
3. **扩展性**:后续功能扩展的架构支持
## 质量保证
### 代码质量
- 代码覆盖率达到80%以上
- 遵循阿里巴巴Java开发规范
- 使用SonarQube进行代码质量检查
### 测试策略
- 单元测试所有Service层方法
- 集成测试所有API接口
- 性能测试:并发用户场景模拟
### 文档要求
- API文档Swagger自动生成
- 部署文档:详细的环境配置和部署步骤
- 运维文档:监控、备份、故障处理指南
---
**备注**:本任务规划基于当前需求和技术选型,在实际开发过程中可能需要根据具体情况进行调整。建议采用敏捷开发模式,按阶段交付,及时收集反馈并迭代优化。

16552
logs/hs-end.log Normal file

File diff suppressed because it is too large Load Diff

259
mvnw vendored Normal file
View File

@ -0,0 +1,259 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.2
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

149
mvnw.cmd vendored Normal file
View File

@ -0,0 +1,149 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.2
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
if ($env:MAVEN_USER_HOME) {
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
}
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

163
pom.xml Normal file
View File

@ -0,0 +1,163 @@
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.lxy</groupId>
<artifactId>hs-end</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>hs-end</name>
<description>hs-end</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Actuator for Health Check -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Swagger/OpenAPI 3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Jackson for JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Apache Commons Lang for Utility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- HTTP Client for AI Service Integration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- File Upload Support -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version>
</dependency>
<!-- Test Dependencies -->
<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>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- H2 Database for Testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,13 @@
package com.lxy.hsend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class HsEndApplication {
public static void main(String[] args) {
SpringApplication.run(HsEndApplication.class, args);
}
}

View File

@ -0,0 +1,71 @@
package com.lxy.hsend.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 限流注解
* 用于对接口进行访问频率限制
*
* @author lxy
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流key的前缀默认为方法的全限定名
*/
String key() default "";
/**
* 时间窗口内允许的最大请求次数
*/
int count() default 100;
/**
* 时间窗口大小
*/
int period() default 60;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 限流类型
*/
LimitType limitType() default LimitType.IP;
/**
* 超出限制时的提示信息
*/
String message() default "访问频率过高,请稍后再试";
/**
* 限流类型枚举
*/
enum LimitType {
/**
* 根据IP地址限流
*/
IP,
/**
* 根据用户ID限流
*/
USER,
/**
* 根据自定义key限流
*/
CUSTOM,
/**
* 全局限流
*/
GLOBAL
}
}

View File

@ -0,0 +1,90 @@
package com.lxy.hsend.annotation;
import java.lang.annotation.*;
/**
* 请求日志注解
* 用于标记需要记录请求日志的接口
*
* @author lxy
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestLog {
/**
* 操作描述
*/
String value() default "";
/**
* 是否记录请求参数
*/
boolean logParams() default true;
/**
* 是否记录响应结果
*/
boolean logResult() default true;
/**
* 是否记录执行时间
*/
boolean logTime() default true;
/**
* 操作类型
*/
OperationType type() default OperationType.OTHER;
/**
* 操作类型枚举
*/
enum OperationType {
/**
* 查询操作
*/
QUERY,
/**
* 新增操作
*/
CREATE,
/**
* 更新操作
*/
UPDATE,
/**
* 删除操作
*/
DELETE,
/**
* 登录操作
*/
LOGIN,
/**
* 登出操作
*/
LOGOUT,
/**
* 文件上传
*/
UPLOAD,
/**
* 文件下载
*/
DOWNLOAD,
/**
* 其他操作
*/
OTHER
}
}

View File

@ -0,0 +1,25 @@
package com.lxy.hsend.annotation;
import java.lang.annotation.*;
/**
* 需要认证的注解
* 用于标记需要用户登录才能访问的接口
*
* @author lxy
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireAuth {
/**
* 是否可选认证如果有token则验证没有token则不验证
*/
boolean optional() default false;
/**
* 描述信息
*/
String value() default "";
}

View File

@ -0,0 +1,25 @@
package com.lxy.hsend.annotation;
import com.lxy.hsend.annotation.validator.EmailValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 邮箱格式验证注解
*
* @author lxy
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EmailValidator.class)
@Documented
public @interface ValidEmail {
String message() default "邮箱格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,25 @@
package com.lxy.hsend.annotation;
import com.lxy.hsend.annotation.validator.PasswordValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 密码格式验证注解
*
* @author lxy
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
@Documented
public @interface ValidPassword {
String message() default "密码格式不正确密码长度应为6-20位包含字母和数字";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -0,0 +1,29 @@
package com.lxy.hsend.annotation.validator;
import com.lxy.hsend.annotation.ValidEmail;
import com.lxy.hsend.util.StringUtil;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* 邮箱格式验证器
*
* @author lxy
*/
public class EmailValidator implements ConstraintValidator<ValidEmail, String> {
@Override
public void initialize(ValidEmail constraintAnnotation) {
// 初始化方法,可以获取注解参数
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
// 空值交给@NotNull等注解处理
if (email == null) {
return true;
}
return StringUtil.isValidEmail(email);
}
}

View File

@ -0,0 +1,29 @@
package com.lxy.hsend.annotation.validator;
import com.lxy.hsend.annotation.ValidPassword;
import com.lxy.hsend.util.StringUtil;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* 密码格式验证器
*
* @author lxy
*/
public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {
@Override
public void initialize(ValidPassword constraintAnnotation) {
// 初始化方法,可以获取注解参数
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
// 空值交给@NotNull等注解处理
if (password == null) {
return true;
}
return StringUtil.isValidPassword(password);
}
}

View File

@ -0,0 +1,123 @@
package com.lxy.hsend.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 统一API响应格式
*
* @author lxy
* @param <T> 响应数据类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
/**
* 错误码0表示成功
*/
private Integer error;
/**
* 响应数据
*/
private T body;
/**
* 响应消息
*/
private String message;
/**
* 是否成功
*/
private Boolean success;
/**
* 成功响应(无数据)
*/
public static <T> ApiResponse<T> success() {
return new ApiResponse<T>(0, null, "操作成功", true);
}
/**
* 成功响应(带数据)
*/
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<T>(0, data, "操作成功", true);
}
/**
* 成功响应(带数据和消息)
*/
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<T>(0, data, message, true);
}
/**
* 失败响应
*/
public static <T> ApiResponse<T> error(Integer errorCode, String message) {
return new ApiResponse<T>(errorCode, null, message, false);
}
/**
* 失败响应(带数据)
*/
public static <T> ApiResponse<T> error(Integer errorCode, String message, T data) {
return new ApiResponse<T>(errorCode, data, message, false);
}
/**
* 参数验证错误
*/
public static <T> ApiResponse<T> validationError(String message) {
return new ApiResponse<T>(1001, null, message, false);
}
/**
* 认证失败
*/
public static <T> ApiResponse<T> authError(String message) {
return new ApiResponse<T>(1002, null, message, false);
}
/**
* 权限不足
*/
public static <T> ApiResponse<T> forbiddenError(String message) {
return new ApiResponse<T>(1003, null, message, false);
}
/**
* 资源不存在
*/
public static <T> ApiResponse<T> notFoundError(String message) {
return new ApiResponse<T>(1004, null, message, false);
}
/**
* 业务逻辑错误
*/
public static <T> ApiResponse<T> businessError(String message) {
return new ApiResponse<T>(2001, null, message, false);
}
/**
* 系统错误
*/
public static <T> ApiResponse<T> systemError(String message) {
return new ApiResponse<T>(5001, null, message, false);
}
/**
* 未知错误
*/
public static <T> ApiResponse<T> unknownError() {
return new ApiResponse<T>(5002, null, "系统出现未知错误", false);
}
}

View File

@ -0,0 +1,86 @@
package com.lxy.hsend.common;
/**
* 应用常量
*
* @author lxy
*/
public class Constants {
// JWT相关常量
public static final String JWT_SECRET_KEY = "hs-end-jwt-secret-key-2024-secure-256bit-hmac-sha256";
public static final String JWT_TOKEN_PREFIX = "Bearer ";
public static final String JWT_HEADER_NAME = "Authorization";
public static final long JWT_EXPIRATION_TIME = 7 * 24 * 60 * 60 * 1000L; // 7天
public static final long JWT_REFRESH_TIME = 30 * 24 * 60 * 60 * 1000L; // 30天
// Redis相关常量
public static final String REDIS_USER_SESSION_PREFIX = "user:session:";
public static final String REDIS_JWT_BLACKLIST_PREFIX = "jwt:blacklist:";
public static final String REDIS_USER_INFO_PREFIX = "user:info:";
public static final String REDIS_RATE_LIMIT_PREFIX = "rate:limit:";
public static final long REDIS_SESSION_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7天
public static final long REDIS_USER_INFO_EXPIRE_TIME = 30 * 60; // 30分钟
// 分页相关常量
public static final int DEFAULT_PAGE_SIZE = 10;
public static final int MAX_PAGE_SIZE = 100;
public static final int DEFAULT_PAGE_NUM = 1;
// 文件上传相关常量
public static final long MAX_FILE_SIZE = 10 * 1024 * 1024L; // 10MB
public static final String[] ALLOWED_FILE_TYPES = {
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "text/plain", "text/markdown",
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
};
public static final String FILE_UPLOAD_PATH = "uploads/";
// 用户相关常量
public static final int PASSWORD_MIN_LENGTH = 6;
public static final int PASSWORD_MAX_LENGTH = 20;
public static final int NICKNAME_MIN_LENGTH = 1;
public static final int NICKNAME_MAX_LENGTH = 50;
// 会话相关常量
public static final int SESSION_TITLE_MAX_LENGTH = 100;
public static final int MESSAGE_CONTENT_MAX_LENGTH = 10000;
// API相关常量
public static final String API_PREFIX = "/api";
public static final String API_VERSION = "v1";
// 时间格式常量
public static final String DATE_FORMAT = "yyyy-MM-dd";
public static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String TIMEZONE = "Asia/Shanghai";
// 请求限流常量
public static final int RATE_LIMIT_REQUESTS = 100; // 每分钟最大请求数
public static final int RATE_LIMIT_WINDOW = 60; // 时间窗口(秒)
// 邮箱验证正则
public static final String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$";
// 密码验证正则(至少包含字母和数字)
public static final String PASSWORD_REGEX = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{6,20}$";
// 用户状态
public static final byte USER_STATUS_NORMAL = 1;
public static final byte USER_STATUS_LOCKED = 2;
public static final byte USER_STATUS_DELETED = 3;
// 消息角色
public static final String MESSAGE_ROLE_USER = "user";
public static final String MESSAGE_ROLE_ASSISTANT = "assistant";
public static final String MESSAGE_ROLE_SYSTEM = "system";
// AI模型
public static final String AI_MODEL_GPT35 = "gpt-3.5-turbo";
public static final String AI_MODEL_GPT4 = "gpt-4";
public static final String AI_MODEL_CLAUDE = "claude-3";
private Constants() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,74 @@
package com.lxy.hsend.common;
/**
* 错误码枚举
*
* @author lxy
*/
public enum ErrorCode {
// 成功
SUCCESS(0, "操作成功"),
// 客户端错误 1xxx
VALIDATION_ERROR(1001, "参数验证失败"),
AUTH_ERROR(1002, "认证失败"),
FORBIDDEN_ERROR(1003, "权限不足"),
NOT_FOUND_ERROR(1004, "资源不存在"),
METHOD_NOT_ALLOWED(1005, "请求方法不支持"),
RATE_LIMIT_ERROR(1006, "请求频率超限"),
// 业务逻辑错误 2xxx
BUSINESS_ERROR(2001, "业务逻辑错误"),
USER_NOT_FOUND(2002, "用户不存在"),
USER_ALREADY_EXISTS(2003, "用户已存在"),
RESOURCE_NOT_FOUND(2005, "资源不存在"),
PASSWORD_ERROR(2004, "密码错误"),
SESSION_NOT_FOUND(2005, "会话不存在"),
MESSAGE_NOT_FOUND(2006, "消息不存在"),
FILE_NOT_FOUND(2007, "文件不存在"),
FILE_UPLOAD_ERROR(2008, "文件上传失败"),
AI_SERVICE_ERROR(2009, "AI服务调用失败"),
// 第三方服务错误 3xxx
EXTERNAL_SERVICE_ERROR(3001, "第三方服务异常"),
AI_API_ERROR(3002, "AI接口调用失败"),
REDIS_ERROR(3003, "Redis服务异常"),
// 数据访问错误 4xxx
DATABASE_ERROR(4001, "数据库操作失败"),
DATA_INTEGRITY_ERROR(4002, "数据完整性约束违反"),
TRANSACTION_ERROR(4003, "事务处理失败"),
// 系统错误 5xxx
SYSTEM_ERROR(5001, "系统内部错误"),
UNKNOWN_ERROR(5002, "未知错误");
private final Integer code;
private final String message;
ErrorCode(Integer code, String message) {
this.code = code;
this.message = message;
}
public Integer getCode() {
return code;
}
public String getMessage() {
return message;
}
/**
* 根据错误码获取枚举
*/
public static ErrorCode getByCode(Integer code) {
for (ErrorCode errorCode : values()) {
if (errorCode.getCode().equals(code)) {
return errorCode;
}
}
return UNKNOWN_ERROR;
}
}

View File

@ -0,0 +1,159 @@
package com.lxy.hsend.config;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.util.JwtUtil;
import com.lxy.hsend.util.StringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
* JWT认证拦截器
*
* @author lxy
*/
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final ObjectMapper objectMapper;
// 不需要认证的路径
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/api/auth/",
"/api/health/",
"/swagger-ui/",
"/v3/api-docs/",
"/actuator/",
"/static/",
"/uploads/"
);
// 可选认证的路径如果有token则验证没有token也放行
private static final List<String> OPTIONAL_AUTH_PATHS = Arrays.asList(
"/api/user/\\d+" // 匹配 /api/user/{userId} 格式
);
@Autowired
public JwtAuthenticationFilter(JwtUtil jwtUtil, ObjectMapper objectMapper) {
this.jwtUtil = jwtUtil;
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
String method = request.getMethod();
log.debug("JWT过滤器处理请求: {} {}", method, requestURI);
// 跳过OPTIONS请求
if ("OPTIONS".equals(method)) {
filterChain.doFilter(request, response);
return;
}
try {
// 检查是否在排除列表中
if (isExcludePath(requestURI)) {
log.debug("路径在排除列表中,跳过认证: {}", requestURI);
filterChain.doFilter(request, response);
return;
}
// 检查是否为可选认证路径
boolean isOptionalAuth = isOptionalAuthPath(requestURI);
// 获取Token
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
// 如果没有Token
if (StringUtil.isEmpty(token)) {
if (isOptionalAuth) {
log.debug("可选认证路径没有token继续执行: {}", requestURI);
filterChain.doFilter(request, response);
return;
} else {
log.warn("缺少认证Token: {}", requestURI);
writeErrorResponse(response, ErrorCode.AUTH_ERROR, "请提供有效的认证Token");
return;
}
}
// 验证Token
if (!jwtUtil.validateToken(token)) {
log.warn("Token验证失败: {}", requestURI);
writeErrorResponse(response, ErrorCode.AUTH_ERROR, "Token无效或已过期请重新登录");
return;
}
// Token验证通过设置用户信息到请求属性
Long userId = jwtUtil.getUserIdFromToken(token);
String email = jwtUtil.getEmailFromToken(token);
if (userId != null && email != null) {
request.setAttribute("currentUserId", userId);
request.setAttribute("currentUserEmail", email);
log.debug("JWT认证成功: userId={}, email={}, uri={}", userId, email, requestURI);
} else {
log.warn("Token中用户信息不完整: {}", requestURI);
writeErrorResponse(response, ErrorCode.AUTH_ERROR, "Token信息不完整");
return;
}
} catch (Exception e) {
log.error("JWT认证异常: {} - {}", requestURI, e.getMessage(), e);
writeErrorResponse(response, ErrorCode.SYSTEM_ERROR, "认证系统异常");
return;
}
filterChain.doFilter(request, response);
}
/**
* 检查路径是否在排除列表中
*/
private boolean isExcludePath(String requestURI) {
return EXCLUDE_PATHS.stream().anyMatch(requestURI::startsWith);
}
/**
* 检查是否为可选认证路径
*/
private boolean isOptionalAuthPath(String requestURI) {
return OPTIONAL_AUTH_PATHS.stream()
.anyMatch(pattern -> requestURI.matches("/api/user/\\d+"));
}
/**
* 写入错误响应
*/
private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
ApiResponse<Object> errorResponse = ApiResponse.error(errorCode.getCode(), message);
String json = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(json);
response.getWriter().flush();
}
}

View File

@ -0,0 +1,88 @@
package com.lxy.hsend.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.lxy.hsend.common.Constants;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis配置类
* 配置Redis连接和序列化方式
*
* @author lxy
*/
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 创建ObjectMapper实例并配置
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// 使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
jackson2JsonRedisSerializer.setObjectMapper(mapper);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
/**
* 缓存管理器配置
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 创建ObjectMapper实例并配置
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// JSON序列化配置
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
jackson2JsonRedisSerializer.setObjectMapper(mapper);
// 配置序列化
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(Constants.REDIS_USER_INFO_EXPIRE_TIME)) // 默认缓存时间30分钟
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues(); // 不缓存null值
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.transactionAware()
.build();
}
}

View File

@ -0,0 +1,70 @@
package com.lxy.hsend.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security配置类
*
* @author lxy
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF保护使用JWT不需要CSRF保护
.csrf(AbstractHttpConfigurer::disable)
// 禁用默认的Session管理使用无状态JWT
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 禁用默认的登录表单
.formLogin(AbstractHttpConfigurer::disable)
// 禁用HTTP Basic认证
.httpBasic(AbstractHttpConfigurer::disable)
// 配置请求授权
.authorizeHttpRequests(authz -> authz
// 公开接口,无需认证
.requestMatchers("/api/health/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/user/{userId}").permitAll() // 公开的用户信息接口
// Swagger相关接口
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
// Actuator监控接口
.requestMatchers("/actuator/**").permitAll()
// 静态资源
.requestMatchers("/static/**", "/uploads/**").permitAll()
// 其他所有请求需要认证
.anyRequest().authenticated()
)
// 添加JWT认证拦截器在Spring Security的UsernamePasswordAuthenticationFilter之前执行
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@ -0,0 +1,42 @@
package com.lxy.hsend.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger配置类
* 配置API文档生成
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("海角AI - API接口文档")
.version("1.0.0")
.description("海角AI后端服务API接口文档提供用户认证、聊天会话、消息处理、收藏管理、文件上传等功能。")
.contact(new Contact()
.name("海角AI团队")
.email("contact@haijiao.ai"))
.license(new License()
.name("MIT License")
.url("https://opensource.org/licenses/MIT")))
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT认证令牌格式Bearer <token>")))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
}
}

View File

@ -0,0 +1,36 @@
package com.lxy.hsend.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web配置类
* 配置跨域访问等Web相关设置
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${cors.allowed-origins}")
private String allowedOrigins;
@Value("${cors.allowed-methods}")
private String allowedMethods;
@Value("${cors.allowed-headers}")
private String allowedHeaders;
@Value("${cors.allow-credentials}")
private boolean allowCredentials;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns(allowedOrigins.split(","))
.allowedMethods(allowedMethods.split(","))
.allowedHeaders(allowedHeaders)
.allowCredentials(allowCredentials)
.maxAge(3600);
}
}

View File

@ -0,0 +1,95 @@
package com.lxy.hsend.config;
import com.lxy.hsend.common.Constants;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
/**
* Web MVC配置
*
* @author lxy
*/
@Configuration
@EnableWebMvc
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 配置CORS跨域
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
/**
* 配置静态资源处理
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 文件上传资源
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + Constants.FILE_UPLOAD_PATH);
// Swagger UI资源
registry.addResourceHandler("/swagger-ui/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/springdoc-openapi-ui/");
// 静态资源
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
/**
* 配置路径匹配
*/
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// 设置路径匹配策略
configurer.setUseTrailingSlashMatch(true);
// 设置后缀模式匹配
configurer.setUseSuffixPatternMatch(false);
}
/**
* 配置内容协商
*/
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.defaultContentType(org.springframework.http.MediaType.APPLICATION_JSON)
.favorParameter(false)
.ignoreAcceptHeader(false);
}
/**
* 添加拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 这里可以添加自定义拦截器
// 例如:认证拦截器、日志拦截器等
}
/**
* 配置视图解析器
*/
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
// 配置JSON视图解析器
registry.enableContentNegotiation();
}
/**
* 配置消息转换器
*/
@Override
public void configureMessageConverters(java.util.List<org.springframework.http.converter.HttpMessageConverter<?>> converters) {
// Spring Boot会自动配置Jackson消息转换器
WebMvcConfigurer.super.configureMessageConverters(converters);
}
}

View File

@ -0,0 +1,130 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.auth.LoginRequest;
import com.lxy.hsend.dto.auth.LoginResponse;
import com.lxy.hsend.dto.auth.RegisterRequest;
import com.lxy.hsend.dto.user.UserInfoResponse;
import com.lxy.hsend.service.UserService;
import com.lxy.hsend.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
/**
* 认证控制器
*
* @author lxy
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@Tag(name = "认证管理", description = "用户认证相关接口")
public class AuthController {
private final UserService userService;
private final JwtUtil jwtUtil;
@Autowired
public AuthController(UserService userService, JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
/**
* 用户注册
*/
@PostMapping("/register")
@Operation(summary = "用户注册", description = "通过邮箱和密码注册新用户")
@RequestLog(value = "用户注册", type = RequestLog.OperationType.CREATE)
public ApiResponse<UserInfoResponse> register(@Valid @RequestBody RegisterRequest request) {
UserInfoResponse userInfo = userService.register(request);
return ApiResponse.success(userInfo, "注册成功");
}
/**
* 用户登录
*/
@PostMapping("/login")
@Operation(summary = "用户登录", description = "通过邮箱和密码登录")
@RequestLog(value = "用户登录", type = RequestLog.OperationType.LOGIN)
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse loginResponse = userService.login(request);
return ApiResponse.success(loginResponse, "登录成功");
}
/**
* 用户登出
*/
@PostMapping("/logout")
@RequireAuth
@Operation(summary = "用户登出", description = "用户登出将token加入黑名单")
@RequestLog(value = "用户登出", type = RequestLog.OperationType.LOGOUT)
public ApiResponse<Void> logout(HttpServletRequest request) {
// 从请求头获取token
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
if (token != null) {
// 从token获取用户ID
Long userId = jwtUtil.getUserIdFromToken(token);
if (userId != null) {
userService.logout(userId, token);
}
}
return ApiResponse.success(null, "登出成功");
}
/**
* 刷新Token
*/
@PostMapping("/refresh")
@Operation(summary = "刷新Token", description = "使用刷新Token获取新的访问Token")
@RequestLog(value = "刷新Token", type = RequestLog.OperationType.OTHER)
public ApiResponse<LoginResponse> refreshToken(@RequestParam String refreshToken) {
try {
// 验证刷新Token
if (!jwtUtil.validateToken(refreshToken)) {
return ApiResponse.authError("刷新Token无效");
}
String tokenType = jwtUtil.getTokenTypeFromToken(refreshToken);
if (!"refresh".equals(tokenType)) {
return ApiResponse.authError("Token类型错误");
}
// 生成新的访问Token
String newAccessToken = jwtUtil.refreshToken(refreshToken);
Long userId = jwtUtil.getUserIdFromToken(refreshToken);
// 获取用户信息
UserInfoResponse userInfo = userService.getUserById(userId);
LoginResponse loginResponse = new LoginResponse(newAccessToken, refreshToken,
com.lxy.hsend.common.Constants.JWT_EXPIRATION_TIME / 1000, userInfo);
return ApiResponse.success(loginResponse, "Token刷新成功");
} catch (Exception e) {
log.error("Token刷新失败: {}", e.getMessage());
return ApiResponse.authError("Token刷新失败");
}
}
/**
* 检查邮箱是否已存在
*/
@GetMapping("/check-email")
@Operation(summary = "检查邮箱", description = "检查邮箱是否已被注册")
public ApiResponse<Boolean> checkEmail(@RequestParam String email) {
boolean exists = userService.existsByEmail(email);
return ApiResponse.success(exists, exists ? "邮箱已存在" : "邮箱可用");
}
}

View File

@ -0,0 +1,128 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RateLimit;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.favorite.AddFavoriteRequest;
import com.lxy.hsend.dto.favorite.FavoriteListRequest;
import com.lxy.hsend.dto.favorite.FavoriteResponse;
import com.lxy.hsend.dto.favorite.RemoveFavoriteRequest;
import com.lxy.hsend.service.FavoriteService;
import com.lxy.hsend.util.PageUtil;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 收藏控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/favorites")
@RequiredArgsConstructor
@Tag(name = "收藏管理", description = "消息收藏相关接口")
public class FavoriteController {
private final FavoriteService favoriteService;
/**
* 添加收藏
*/
@PostMapping("/add")
@RequireAuth
@RequestLog
@RateLimit(count = 60, period = 60) // 每分钟最多60次收藏操作
@Operation(summary = "添加收藏", description = "将指定消息添加到收藏列表")
public ApiResponse<FavoriteResponse> addFavorite(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid AddFavoriteRequest request) {
log.info("用户添加收藏: user={}, messageId={}, sessionId={}",
userDetails.getUsername(), request.getMessageId(), request.getSessionId());
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.addFavorite(userId, request);
}
/**
* 取消收藏
*/
@PostMapping("/remove")
@RequireAuth
@RequestLog
@RateLimit(count = 60, period = 60) // 每分钟最多60次取消收藏操作
@Operation(summary = "取消收藏", description = "从收藏列表中移除指定消息")
public ApiResponse<Void> removeFavorite(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid RemoveFavoriteRequest request) {
log.info("用户取消收藏: user={}, messageId={}",
userDetails.getUsername(), request.getMessageId());
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.removeFavorite(userId, request);
}
/**
* 获取收藏列表
*/
@GetMapping("/list")
@RequireAuth
@RequestLog
@Operation(summary = "获取收藏列表", description = "分页获取用户的收藏消息列表,支持关键词搜索")
public ApiResponse<PageUtil.PageResponse<FavoriteResponse>> getFavoriteList(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword,
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize,
@Parameter(description = "消息角色过滤") @RequestParam(defaultValue = "all") String messageRole) {
log.info("获取收藏列表: user={}, keyword={}, page={}, pageSize={}, messageRole={}",
userDetails.getUsername(), keyword, page, pageSize, messageRole);
Long userId = Long.parseLong(userDetails.getUsername());
FavoriteListRequest request = new FavoriteListRequest(keyword, page, pageSize, messageRole);
return favoriteService.getFavoriteList(userId, request);
}
/**
* 检查消息是否被收藏
*/
@GetMapping("/check/{messageId}")
@RequireAuth
@RequestLog
@Operation(summary = "检查收藏状态", description = "检查指定消息是否已被当前用户收藏")
public ApiResponse<Boolean> checkFavoriteStatus(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "消息ID") @PathVariable String messageId) {
log.info("检查收藏状态: user={}, messageId={}", userDetails.getUsername(), messageId);
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.isFavorited(userId, messageId);
}
/**
* 获取用户收藏数量
*/
@GetMapping("/count")
@RequireAuth
@RequestLog
@Operation(summary = "获取收藏数量", description = "获取当前用户的总收藏数量")
public ApiResponse<Long> getFavoriteCount(
@AuthenticationPrincipal UserDetails userDetails) {
log.info("获取收藏数量: user={}", userDetails.getUsername());
Long userId = Long.parseLong(userDetails.getUsername());
return favoriteService.getUserFavoriteCount(userId);
}
}

View File

@ -0,0 +1,47 @@
package com.lxy.hsend.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 健康检查控制器
* 提供系统健康状态检查接口
*/
@Tag(name = "健康检查", description = "系统健康状态检查相关接口")
@RestController
@RequestMapping("/api/health")
public class HealthController {
@Operation(summary = "系统健康检查", description = "检查系统运行状态")
@GetMapping("/check")
public ResponseEntity<Map<String, Object>> healthCheck() {
Map<String, Object> response = new HashMap<>();
response.put("status", "UP");
response.put("timestamp", LocalDateTime.now());
response.put("service", "hs-end");
response.put("version", "1.0.0");
return ResponseEntity.ok(response);
}
@Operation(summary = "系统信息", description = "获取系统基本信息")
@GetMapping("/info")
public ResponseEntity<Map<String, Object>> systemInfo() {
Map<String, Object> response = new HashMap<>();
response.put("application", "海角AI后端服务");
response.put("version", "1.0.0");
response.put("java.version", System.getProperty("java.version"));
response.put("os.name", System.getProperty("os.name"));
response.put("startup.time", LocalDateTime.now());
return ResponseEntity.ok(response);
}
}

View File

@ -0,0 +1,92 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RateLimit;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.message.MessageListRequest;
import com.lxy.hsend.dto.message.MessageResponse;
import com.lxy.hsend.dto.message.SendMessageRequest;
import com.lxy.hsend.service.MessageService;
import com.lxy.hsend.util.PageUtil;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 消息控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
@Tag(name = "消息管理", description = "聊天消息相关接口")
public class MessageController {
private final MessageService messageService;
/**
* 发送消息
*/
@PostMapping("/send")
@RequireAuth
@RequestLog
@RateLimit(count = 30, period = 60) // 每分钟最多30条消息
@Operation(summary = "发送消息", description = "发送聊天消息并获取AI响应")
public ApiResponse<MessageResponse> sendMessage(
@AuthenticationPrincipal UserDetails userDetails,
@RequestBody @Valid SendMessageRequest request) {
log.info("用户发送消息: user={}, sessionId={}, contentLength={}",
userDetails.getUsername(), request.getSessionId(), request.getContent().length());
Long userId = Long.parseLong(userDetails.getUsername());
return messageService.sendMessage(userId, request);
}
/**
* 获取消息列表
*/
@GetMapping("/messages")
@RequireAuth
@RequestLog
@Operation(summary = "获取消息列表", description = "分页获取指定会话的消息列表")
public ApiResponse<PageUtil.PageResponse<MessageResponse>> getMessages(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "会话ID") @RequestParam String sessionId,
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize,
@Parameter(description = "角色过滤") @RequestParam(defaultValue = "all") String role) {
log.info("获取消息列表: user={}, sessionId={}, page={}, pageSize={}, role={}",
userDetails.getUsername(), sessionId, page, pageSize, role);
Long userId = Long.parseLong(userDetails.getUsername());
MessageListRequest request = new MessageListRequest(sessionId, page, pageSize, role);
return messageService.getMessages(userId, request);
}
/**
* 获取消息详情
*/
@GetMapping("/message/{messageId}")
@RequireAuth
@RequestLog
@Operation(summary = "获取消息详情", description = "根据消息ID获取消息详细信息")
public ApiResponse<MessageResponse> getMessageById(
@AuthenticationPrincipal UserDetails userDetails,
@Parameter(description = "消息ID") @PathVariable String messageId) {
log.info("获取消息详情: user={}, messageId={}", userDetails.getUsername(), messageId);
Long userId = Long.parseLong(userDetails.getUsername());
return messageService.getMessageById(userId, messageId);
}
}

View File

@ -0,0 +1,166 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.session.*;
import com.lxy.hsend.service.SessionService;
import com.lxy.hsend.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
/**
* 会话控制器
*
* @author lxy
*/
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequireAuth
@Tag(name = "会话管理", description = "聊天会话管理相关接口")
public class SessionController {
private final SessionService sessionService;
private final JwtUtil jwtUtil;
@Autowired
public SessionController(SessionService sessionService, JwtUtil jwtUtil) {
this.sessionService = sessionService;
this.jwtUtil = jwtUtil;
}
/**
* 创建会话
*/
@PostMapping("/session")
@Operation(summary = "创建会话", description = "创建一个新的聊天会话")
@RequestLog(value = "创建会话", type = RequestLog.OperationType.CREATE)
public ApiResponse<SessionDetailResponse> createSession(
@Valid @RequestBody CreateSessionRequest request,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
SessionDetailResponse response = sessionService.createSession(userId, request);
return ApiResponse.success(response, "会话创建成功");
}
/**
* 获取会话列表
*/
@GetMapping("/sessions")
@Operation(summary = "获取会话列表", description = "获取用户的会话列表,支持搜索和分页")
@RequestLog(value = "获取会话列表", type = RequestLog.OperationType.QUERY)
public ApiResponse<Page<SessionListResponse>> getSessionList(
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword,
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页大小") @RequestParam(defaultValue = "10") Integer pageSize,
@Parameter(description = "排序字段") @RequestParam(defaultValue = "last_message_time") String sortBy,
@Parameter(description = "排序方向") @RequestParam(defaultValue = "desc") String sortOrder,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
// 构建搜索请求
SessionSearchRequest searchRequest = new SessionSearchRequest();
searchRequest.setKeyword(keyword);
searchRequest.setPage(page);
searchRequest.setPageSize(pageSize);
searchRequest.setSortBy(sortBy);
searchRequest.setSortOrder(sortOrder);
Page<SessionListResponse> response = sessionService.getSessionList(userId, searchRequest);
return ApiResponse.success(response, "获取会话列表成功");
}
/**
* 获取会话详情
*/
@GetMapping("/session/{sessionId}")
@Operation(summary = "获取会话详情", description = "根据会话ID获取会话的详细信息")
@RequestLog(value = "获取会话详情", type = RequestLog.OperationType.QUERY)
public ApiResponse<SessionDetailResponse> getSessionDetail(
@Parameter(description = "会话ID") @PathVariable String sessionId,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
SessionDetailResponse response = sessionService.getSessionDetail(userId, sessionId);
return ApiResponse.success(response, "获取会话详情成功");
}
/**
* 更新会话
*/
@PutMapping("/session/{sessionId}")
@Operation(summary = "更新会话", description = "更新会话信息,如标题等")
@RequestLog(value = "更新会话", type = RequestLog.OperationType.UPDATE)
public ApiResponse<SessionDetailResponse> updateSession(
@Parameter(description = "会话ID") @PathVariable String sessionId,
@Valid @RequestBody UpdateSessionRequest request,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
SessionDetailResponse response = sessionService.updateSession(userId, sessionId, request);
return ApiResponse.success(response, "会话更新成功");
}
/**
* 删除会话
*/
@DeleteMapping("/session/{sessionId}")
@Operation(summary = "删除会话", description = "删除指定的会话及其所有消息和收藏记录")
@RequestLog(value = "删除会话", type = RequestLog.OperationType.DELETE)
public ApiResponse<Void> deleteSession(
@Parameter(description = "会话ID") @PathVariable String sessionId,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
sessionService.deleteSession(userId, sessionId);
return ApiResponse.success(null, "会话删除成功");
}
/**
* 清空会话
*/
@PostMapping("/session/{sessionId}/clear")
@Operation(summary = "清空会话", description = "清空会话中的所有消息,但保留会话本身")
@RequestLog(value = "清空会话", type = RequestLog.OperationType.DELETE)
public ApiResponse<Void> clearSession(
@Parameter(description = "会话ID") @PathVariable String sessionId,
HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
sessionService.clearSession(userId, sessionId);
return ApiResponse.success(null, "会话清空成功");
}
// 获取会话消息列表的功能现在由 MessageController 处理
/**
* 从请求中获取当前用户ID
*/
private Long getCurrentUserId(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
if (token != null && jwtUtil.validateToken(token)) {
return jwtUtil.getUserIdFromToken(token);
}
throw new RuntimeException("无法获取用户信息");
}
}

View File

@ -0,0 +1,98 @@
package com.lxy.hsend.controller;
import com.lxy.hsend.annotation.RequestLog;
import com.lxy.hsend.annotation.RequireAuth;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.user.UpdateUserRequest;
import com.lxy.hsend.dto.user.UserInfoResponse;
import com.lxy.hsend.service.UserService;
import com.lxy.hsend.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
/**
* 用户控制器
*
* @author lxy
*/
@Slf4j
@RestController
@RequestMapping("/api/user")
@Tag(name = "用户管理", description = "用户信息相关接口")
public class UserController {
private final UserService userService;
private final JwtUtil jwtUtil;
@Autowired
public UserController(UserService userService, JwtUtil jwtUtil) {
this.userService = userService;
this.jwtUtil = jwtUtil;
}
/**
* 获取当前用户信息
*/
@GetMapping("/info")
@RequireAuth
@Operation(summary = "获取用户信息", description = "获取当前登录用户的基本信息")
@RequestLog(value = "获取用户信息", type = RequestLog.OperationType.QUERY)
public ApiResponse<UserInfoResponse> getUserInfo(HttpServletRequest request) {
Long userId = getCurrentUserId(request);
UserInfoResponse userInfo = userService.getUserById(userId);
return ApiResponse.success(userInfo, "获取用户信息成功");
}
/**
* 更新当前用户信息
*/
@PutMapping("/info")
@RequireAuth
@Operation(summary = "更新用户信息", description = "更新当前登录用户的基本信息")
@RequestLog(value = "更新用户信息", type = RequestLog.OperationType.UPDATE)
public ApiResponse<UserInfoResponse> updateUserInfo(@Valid @RequestBody UpdateUserRequest request, HttpServletRequest httpRequest) {
Long userId = getCurrentUserId(httpRequest);
UserInfoResponse userInfo = userService.updateUser(userId, request);
return ApiResponse.success(userInfo, "更新用户信息成功");
}
/**
* 根据ID获取用户信息公开接口用于获取其他用户的基本信息
*/
@GetMapping("/{userId}")
@Operation(summary = "根据ID获取用户信息", description = "获取指定用户的基本信息(公开信息)")
@RequestLog(value = "根据ID获取用户信息", type = RequestLog.OperationType.QUERY)
public ApiResponse<UserInfoResponse> getUserById(@PathVariable Long userId) {
UserInfoResponse userInfo = userService.getUserById(userId);
// 对于公开接口,隐藏敏感信息
UserInfoResponse publicInfo = new UserInfoResponse();
publicInfo.setUserId(userInfo.getUserId());
publicInfo.setNickname(userInfo.getNickname());
publicInfo.setAvatarUrl(userInfo.getAvatarUrl());
publicInfo.setCreatedAt(userInfo.getCreatedAt());
// 不返回邮箱、状态、最后登录时间等敏感信息
return ApiResponse.success(publicInfo, "获取用户信息成功");
}
/**
* 从请求中获取当前用户ID
*/
private Long getCurrentUserId(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
String token = jwtUtil.extractTokenFromHeader(authHeader);
if (token != null && jwtUtil.validateToken(token)) {
return jwtUtil.getUserIdFromToken(token);
}
throw new RuntimeException("无法获取用户信息");
}
}

View File

@ -0,0 +1,33 @@
package com.lxy.hsend.dto.auth;
import com.lxy.hsend.annotation.ValidEmail;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 用户登录请求DTO
*
* @author lxy
*/
@Data
public class LoginRequest {
/**
* 邮箱地址
*/
@NotBlank(message = "邮箱不能为空")
@ValidEmail(message = "邮箱格式不正确")
private String email;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
private String password;
/**
* 记住登录状态(可选,暂时保留)
*/
private Boolean rememberMe = false;
}

View File

@ -0,0 +1,53 @@
package com.lxy.hsend.dto.auth;
import com.lxy.hsend.dto.user.UserInfoResponse;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户登录响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
/**
* JWT访问令牌
*/
private String accessToken;
/**
* JWT刷新令牌
*/
private String refreshToken;
/**
* 令牌类型
*/
private String tokenType = "Bearer";
/**
* 令牌过期时间(秒)
*/
private Long expiresIn;
/**
* 用户基本信息
*/
private UserInfoResponse userInfo;
/**
* 构造方法不包含tokenType
*/
public LoginResponse(String accessToken, String refreshToken, Long expiresIn, UserInfoResponse userInfo) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenType = "Bearer";
this.expiresIn = expiresIn;
this.userInfo = userInfo;
}
}

View File

@ -0,0 +1,49 @@
package com.lxy.hsend.dto.auth;
import com.lxy.hsend.annotation.ValidEmail;
import com.lxy.hsend.annotation.ValidPassword;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 用户注册请求DTO
*
* @author lxy
*/
@Data
public class RegisterRequest {
/**
* 邮箱地址
*/
@NotBlank(message = "邮箱不能为空")
@ValidEmail(message = "邮箱格式不正确")
private String email;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
@ValidPassword(message = "密码格式不正确密码长度应为6-20位包含字母和数字")
private String password;
/**
* 确认密码
*/
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
/**
* 用户昵称
*/
@NotBlank(message = "昵称不能为空")
@Size(min = 1, max = 50, message = "昵称长度应在1-50个字符之间")
private String nickname;
/**
* 头像URL可选
*/
private String avatarUrl;
}

View File

@ -0,0 +1,28 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 添加收藏请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddFavoriteRequest {
/**
* 消息ID
*/
@NotBlank(message = "消息ID不能为空")
private String messageId;
/**
* 会话ID
*/
@NotBlank(message = "会话ID不能为空")
private String sessionId;
}

View File

@ -0,0 +1,40 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 收藏列表查询请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FavoriteListRequest {
/**
* 搜索关键词(会话标题、消息内容)
*/
private String keyword;
/**
* 页码从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页数量
*/
@Min(value = 1, message = "每页数量必须大于0")
@Max(value = 100, message = "每页数量不能超过100")
private Integer pageSize = 20;
/**
* 消息类型过滤user/assistant/all
*/
private String messageRole = "all";
}

View File

@ -0,0 +1,58 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime;
/**
* 收藏响应DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FavoriteResponse {
/**
* 收藏ID
*/
private String favoriteId;
/**
* 消息ID
*/
private String messageId;
/**
* 会话ID
*/
private String sessionId;
/**
* 会话标题
*/
private String sessionTitle;
/**
* 消息内容
*/
private String messageContent;
/**
* 消息角色user/assistant
*/
private String messageRole;
/**
* 消息创建时间
*/
private LocalDateTime messageCreatedAt;
/**
* 收藏时间
*/
private LocalDateTime favoritedAt;
}

View File

@ -0,0 +1,22 @@
package com.lxy.hsend.dto.favorite;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
/**
* 取消收藏请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RemoveFavoriteRequest {
/**
* 消息ID
*/
@NotBlank(message = "消息ID不能为空")
private String messageId;
}

View File

@ -0,0 +1,42 @@
package com.lxy.hsend.dto.message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 消息列表查询请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MessageListRequest {
/**
* 会话ID
*/
@NotBlank(message = "会话ID不能为空")
private String sessionId;
/**
* 页码从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页数量
*/
@Min(value = 1, message = "每页数量必须大于0")
@Max(value = 100, message = "每页数量不能超过100")
private Integer pageSize = 20;
/**
* 消息类型过滤user/assistant/all
*/
private String role = "all";
}

View File

@ -0,0 +1,63 @@
package com.lxy.hsend.dto.message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.time.LocalDateTime;
/**
* 消息响应DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MessageResponse {
/**
* 消息ID
*/
private String messageId;
/**
* 会话ID
*/
private String sessionId;
/**
* 消息角色user/assistant
*/
private String role;
/**
* 消息内容
*/
private String content;
/**
* 消息状态pending/completed/failed
*/
private String status;
/**
* 是否深度思考
*/
private Boolean deepThinking;
/**
* 是否联网搜索
*/
private Boolean webSearch;
/**
* 创建时间
*/
private LocalDateTime createdAt;
/**
* 更新时间
*/
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,40 @@
package com.lxy.hsend.dto.message;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 发送消息请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SendMessageRequest {
/**
* 会话ID
*/
@NotBlank(message = "会话ID不能为空")
private String sessionId;
/**
* 消息内容
*/
@NotBlank(message = "消息内容不能为空")
@Size(min = 1, max = 4000, message = "消息内容长度必须在1-4000字符之间")
private String content;
/**
* 是否深度思考
*/
private Boolean deepThinking = false;
/**
* 是否联网搜索
*/
private Boolean webSearch = false;
}

View File

@ -0,0 +1,27 @@
package com.lxy.hsend.dto.session;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 创建会话请求DTO
*
* @author lxy
*/
@Data
public class CreateSessionRequest {
/**
* 会话标题
*/
@NotBlank(message = "会话标题不能为空")
@Size(min = 1, max = 100, message = "会话标题长度应在1-100个字符之间")
private String title;
/**
* 初始消息内容(可选)
*/
@Size(max = 10000, message = "消息内容长度不能超过10000个字符")
private String initialMessage;
}

View File

@ -0,0 +1,65 @@
package com.lxy.hsend.dto.session;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 会话详情响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionDetailResponse {
/**
* 会话ID
*/
private String sessionId;
/**
* 用户ID
*/
private Long userId;
/**
* 会话标题
*/
private String title;
/**
* 消息总数
*/
private Integer messageCount;
/**
* 最后消息时间
*/
private Long lastMessageTime;
/**
* 创建时间
*/
private Long createdAt;
/**
* 更新时间
*/
private Long updatedAt;
/**
* 检查是否有消息
*/
public boolean hasMessages() {
return messageCount != null && messageCount > 0;
}
/**
* 检查会话是否为空(无消息)
*/
public boolean isEmpty() {
return !hasMessages();
}
}

View File

@ -0,0 +1,74 @@
package com.lxy.hsend.dto.session;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 会话列表响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionListResponse {
/**
* 会话ID
*/
private String sessionId;
/**
* 会话标题
*/
private String title;
/**
* 消息总数
*/
private Integer messageCount;
/**
* 最后一条消息的内容摘要
*/
private String lastMessageContent;
/**
* 最后消息时间
*/
private Long lastMessageTime;
/**
* 创建时间
*/
private Long createdAt;
/**
* 更新时间
*/
private Long updatedAt;
/**
* 获取最后消息内容的摘要(限制长度)
*/
public String getLastMessageSummary() {
if (lastMessageContent == null || lastMessageContent.trim().isEmpty()) {
return "暂无消息";
}
String content = lastMessageContent.trim();
if (content.length() <= 50) {
return content;
}
return content.substring(0, 47) + "...";
}
/**
* 检查是否有消息
*/
public boolean hasMessages() {
return messageCount != null && messageCount > 0;
}
}

View File

@ -0,0 +1,88 @@
package com.lxy.hsend.dto.session;
import lombok.Data;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 会话搜索请求DTO
*
* @author lxy
*/
@Data
public class SessionSearchRequest {
/**
* 搜索关键词(标题和消息内容)
*/
private String keyword;
/**
* 页码从1开始
*/
@Min(value = 1, message = "页码必须大于0")
private Integer page = 1;
/**
* 每页大小
*/
@Min(value = 1, message = "每页大小必须大于0")
@Max(value = 100, message = "每页大小不能超过100")
private Integer pageSize = 10;
/**
* 排序字段支持created_at, updated_at, last_message_time
*/
private String sortBy = "last_message_time";
/**
* 排序方向asc: 升序, desc: 降序)
*/
private String sortOrder = "desc";
/**
* 检查是否有搜索关键词
*/
public boolean hasKeyword() {
return keyword != null && !keyword.trim().isEmpty();
}
/**
* 获取清理后的关键词
*/
public String getCleanKeyword() {
return hasKeyword() ? keyword.trim() : null;
}
/**
* 检查排序方向是否为升序
*/
public boolean isAscOrder() {
return "asc".equalsIgnoreCase(sortOrder);
}
/**
* 检查排序方向是否为降序
*/
public boolean isDescOrder() {
return "desc".equalsIgnoreCase(sortOrder);
}
/**
* 获取有效的排序字段
*/
public String getValidSortBy() {
if (sortBy == null) {
return "last_message_time";
}
switch (sortBy.toLowerCase()) {
case "created_at":
case "updated_at":
case "last_message_time":
return sortBy.toLowerCase();
default:
return "last_message_time";
}
}
}

View File

@ -0,0 +1,19 @@
package com.lxy.hsend.dto.session;
import lombok.Data;
import jakarta.validation.constraints.Size;
/**
* 更新会话请求DTO
*
* @author lxy
*/
@Data
public class UpdateSessionRequest {
/**
* 会话标题
*/
@Size(min = 1, max = 100, message = "会话标题长度应在1-100个字符之间")
private String title;
}

View File

@ -0,0 +1,26 @@
package com.lxy.hsend.dto.user;
import lombok.Data;
import jakarta.validation.constraints.Size;
/**
* 更新用户信息请求DTO
*
* @author lxy
*/
@Data
public class UpdateUserRequest {
/**
* 用户昵称
*/
@Size(min = 1, max = 50, message = "昵称长度应在1-50个字符之间")
private String nickname;
/**
* 头像URL
*/
@Size(max = 500, message = "头像URL长度不能超过500个字符")
private String avatarUrl;
}

View File

@ -0,0 +1,93 @@
package com.lxy.hsend.dto.user;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户信息响应DTO
*
* @author lxy
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoResponse {
/**
* 用户ID
*/
private Long userId;
/**
* 邮箱地址
*/
private String email;
/**
* 用户昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatarUrl;
/**
* 用户状态1-正常2-锁定3-删除)
*/
private Byte status;
/**
* 最后登录时间
*/
private Long lastLoginTime;
/**
* 创建时间
*/
private Long createdAt;
/**
* 更新时间
*/
private Long updatedAt;
/**
* 获取状态描述
*/
public String getStatusText() {
switch (status) {
case 1:
return "正常";
case 2:
return "锁定";
case 3:
return "删除";
default:
return "未知";
}
}
/**
* 检查用户状态是否正常
*/
public boolean isNormal() {
return status != null && status == 1;
}
/**
* 检查用户是否被锁定
*/
public boolean isLocked() {
return status != null && status == 2;
}
/**
* 检查用户是否被删除
*/
public boolean isDeleted() {
return status != null && status == 3;
}
}

View File

@ -0,0 +1,181 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 收藏实体类
*/
@Entity
@Table(name = "favorites",
indexes = {
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_message_id", columnList = "messageId"),
@Index(name = "idx_session_id", columnList = "sessionId"),
@Index(name = "idx_created_at", columnList = "createdAt")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_message", columnNames = {"user_id", "message_id"})
}
)
public class Favorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "favorite_id", nullable = false, unique = true, length = 36)
private String favoriteId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(max = 36)
@Column(name = "message_id", nullable = false, length = 36)
private String messageId;
@NotBlank
@Size(max = 36)
@Column(name = "session_id", nullable = false, length = 36)
private String sessionId;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public Favorite() {
long now = Instant.now().getEpochSecond();
this.favoriteId = UUID.randomUUID().toString();
this.createdAt = now;
this.updatedAt = now;
}
public Favorite(Long userId, String messageId, String sessionId) {
this();
this.userId = userId;
this.messageId = messageId;
this.sessionId = sessionId;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFavoriteId() {
return favoriteId;
}
public void setFavoriteId(String favoriteId) {
this.favoriteId = favoriteId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
@Override
public String toString() {
return "Favorite{" +
"id=" + id +
", favoriteId='" + favoriteId + '\'' +
", userId=" + userId +
", messageId='" + messageId + '\'' +
", sessionId='" + sessionId + '\'' +
", createdAt=" + createdAt +
'}';
}
}

View File

@ -0,0 +1,286 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 文件实体类
*/
@Entity
@Table(name = "files", indexes = {
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_session_id", columnList = "sessionId"),
@Index(name = "idx_file_type", columnList = "fileType"),
@Index(name = "idx_upload_time", columnList = "uploadTime"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class File {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "file_id", nullable = false, unique = true, length = 36)
private String fileId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@Size(max = 36)
@Column(name = "session_id", length = 36)
private String sessionId;
@NotBlank
@Size(max = 255)
@Column(name = "filename", nullable = false)
private String filename;
@NotBlank
@Size(max = 255)
@Column(name = "stored_filename", nullable = false)
private String storedFilename;
@NotBlank
@Size(max = 500)
@Column(name = "file_path", nullable = false, length = 500)
private String filePath;
@NotNull
@Column(name = "file_size", nullable = false)
private Long fileSize;
@NotBlank
@Size(max = 100)
@Column(name = "file_type", nullable = false, length = 100)
private String fileType;
@Size(max = 100)
@Column(name = "mime_type", length = 100)
private String mimeType;
@NotNull
@Column(name = "upload_time", nullable = false)
private Long uploadTime;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public File() {
long now = Instant.now().getEpochSecond();
this.fileId = UUID.randomUUID().toString();
this.uploadTime = now;
this.createdAt = now;
this.updatedAt = now;
}
public File(Long userId, String filename, String storedFilename, String filePath,
Long fileSize, String fileType, String mimeType) {
this();
this.userId = userId;
this.filename = filename;
this.storedFilename = storedFilename;
this.filePath = filePath;
this.fileSize = fileSize;
this.fileType = fileType;
this.mimeType = mimeType;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFileId() {
return fileId;
}
public void setFileId(String fileId) {
this.fileId = fileId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Long getUploadTime() {
return uploadTime;
}
public void setUploadTime(Long uploadTime) {
this.uploadTime = uploadTime;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
public String getFileSizeFormatted() {
if (fileSize == null) return "0 B";
long size = fileSize;
String[] units = {"B", "KB", "MB", "GB", "TB"};
int unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return size + " " + units[unitIndex];
}
public boolean isImageFile() {
if (mimeType == null) return false;
return mimeType.startsWith("image/");
}
public boolean isDocumentFile() {
if (fileType == null) return false;
String lowerType = fileType.toLowerCase();
return lowerType.equals("pdf") || lowerType.equals("doc") ||
lowerType.equals("docx") || lowerType.equals("txt") ||
lowerType.equals("md");
}
@Override
public String toString() {
return "File{" +
"id=" + id +
", fileId='" + fileId + '\'' +
", userId=" + userId +
", filename='" + filename + '\'' +
", fileSize=" + fileSize +
", fileType='" + fileType + '\'' +
", uploadTime=" + uploadTime +
'}';
}
}

View File

@ -0,0 +1,266 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 消息实体类
*/
@Entity
@Table(name = "messages", indexes = {
@Index(name = "idx_session_id", columnList = "sessionId"),
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_role", columnList = "role"),
@Index(name = "idx_timestamp", columnList = "timestamp"),
@Index(name = "idx_is_favorited", columnList = "isFavorited"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "message_id", nullable = false, unique = true, length = 36)
private String messageId;
@NotBlank
@Size(max = 36)
@Column(name = "session_id", nullable = false, length = 36)
private String sessionId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(max = 20)
@Column(name = "role", nullable = false, length = 20)
private String role; // user/assistant
@NotBlank
@Lob
@Column(name = "content", nullable = false, columnDefinition = "LONGTEXT")
private String content;
@Size(max = 50)
@Column(name = "model_used", length = 50)
private String modelUsed;
@Column(name = "deep_thinking")
private Boolean deepThinking = false;
@Column(name = "web_search")
private Boolean webSearch = false;
@Column(name = "is_favorited")
private Boolean isFavorited = false;
@NotNull
@Column(name = "timestamp", nullable = false)
private Long timestamp;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public Message() {
long now = Instant.now().getEpochSecond();
this.messageId = UUID.randomUUID().toString();
this.timestamp = now;
this.createdAt = now;
this.updatedAt = now;
}
public Message(String sessionId, Long userId, String role, String content) {
this();
this.sessionId = sessionId;
this.userId = userId;
this.role = role;
this.content = content;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getModelUsed() {
return modelUsed;
}
public void setModelUsed(String modelUsed) {
this.modelUsed = modelUsed;
}
public Boolean getDeepThinking() {
return deepThinking;
}
public void setDeepThinking(Boolean deepThinking) {
this.deepThinking = deepThinking;
}
public Boolean getWebSearch() {
return webSearch;
}
public void setWebSearch(Boolean webSearch) {
this.webSearch = webSearch;
}
public Boolean getIsFavorited() {
return isFavorited;
}
public void setIsFavorited(Boolean isFavorited) {
this.isFavorited = isFavorited;
}
public Long getTimestamp() {
return timestamp;
}
public void setTimestamp(Long timestamp) {
this.timestamp = timestamp;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
public boolean isUserMessage() {
return "user".equals(role);
}
public boolean isAssistantMessage() {
return "assistant".equals(role);
}
public void toggleFavorite() {
this.isFavorited = !Boolean.TRUE.equals(this.isFavorited);
}
// 消息角色常量
public static final String ROLE_USER = "user";
public static final String ROLE_ASSISTANT = "assistant";
@Override
public String toString() {
return "Message{" +
"id=" + id +
", messageId='" + messageId + '\'' +
", sessionId='" + sessionId + '\'' +
", role='" + role + '\'' +
", isFavorited=" + isFavorited +
", timestamp=" + timestamp +
'}';
}
}

View File

@ -0,0 +1,192 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.UUID;
/**
* 会话实体类
*/
@Entity
@Table(name = "sessions", indexes = {
@Index(name = "idx_user_id", columnList = "userId"),
@Index(name = "idx_last_message_time", columnList = "lastMessageTime"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class Session {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 36)
@Column(name = "session_id", nullable = false, unique = true, length = 36)
private String sessionId;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotBlank
@Size(max = 200)
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "message_count")
private Integer messageCount = 0;
@Column(name = "last_message_time")
private Long lastMessageTime;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 关联用户实体
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", insertable = false, updatable = false)
private User user;
// 构造函数
public Session() {
long now = Instant.now().getEpochSecond();
this.sessionId = UUID.randomUUID().toString();
this.createdAt = now;
this.updatedAt = now;
}
public Session(Long userId, String title) {
this();
this.userId = userId;
this.title = title;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Integer getMessageCount() {
return messageCount;
}
public void setMessageCount(Integer messageCount) {
this.messageCount = messageCount;
}
public Long getLastMessageTime() {
return lastMessageTime;
}
public void setLastMessageTime(Long lastMessageTime) {
this.lastMessageTime = lastMessageTime;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
}
public void incrementMessageCount() {
this.messageCount = (this.messageCount == null ? 0 : this.messageCount) + 1;
this.lastMessageTime = Instant.now().getEpochSecond();
}
public void decrementMessageCount() {
this.messageCount = Math.max(0, (this.messageCount == null ? 0 : this.messageCount) - 1);
}
@Override
public String toString() {
return "Session{" +
"id=" + id +
", sessionId='" + sessionId + '\'' +
", userId=" + userId +
", title='" + title + '\'' +
", messageCount=" + messageCount +
", createdAt=" + createdAt +
'}';
}
}

View File

@ -0,0 +1,185 @@
package com.lxy.hsend.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
/**
* 用户实体类
*/
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_created_at", columnList = "createdAt")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Email
@Size(max = 100)
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@NotBlank
@Size(max = 255)
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@NotBlank
@Size(max = 50)
@Column(name = "nickname", nullable = false, length = 50)
private String nickname;
@Size(max = 500)
@Column(name = "avatar_url", length = 500)
private String avatarUrl;
@NotNull
@Column(name = "status", nullable = false, columnDefinition = "TINYINT")
private Byte status = 1; // 1-正常2-锁定3-删除
@Column(name = "last_login_time")
private Long lastLoginTime;
@NotNull
@Column(name = "created_at", nullable = false)
private Long createdAt;
@NotNull
@Column(name = "updated_at", nullable = false)
private Long updatedAt;
@Column(name = "deleted_at")
private Long deletedAt;
// 构造函数
public User() {
long now = Instant.now().getEpochSecond();
this.createdAt = now;
this.updatedAt = now;
}
// JPA生命周期回调
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now().getEpochSecond();
}
// Getters and Setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
}
public Byte getStatus() {
return status;
}
public void setStatus(Byte status) {
this.status = status;
}
public Long getLastLoginTime() {
return lastLoginTime;
}
public void setLastLoginTime(Long lastLoginTime) {
this.lastLoginTime = lastLoginTime;
}
public Long getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Long createdAt) {
this.createdAt = createdAt;
}
public Long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Long updatedAt) {
this.updatedAt = updatedAt;
}
public Long getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(Long deletedAt) {
this.deletedAt = deletedAt;
}
// 工具方法
public boolean isDeleted() {
return deletedAt != null;
}
public boolean isNormal() {
return status == 1;
}
public boolean isLocked() {
return status == 2;
}
public void markAsDeleted() {
this.deletedAt = Instant.now().getEpochSecond();
this.status = 3;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", email='" + email + '\'' +
", nickname='" + nickname + '\'' +
", status=" + status +
", createdAt=" + createdAt +
'}';
}
}

View File

@ -0,0 +1,76 @@
package com.lxy.hsend.exception;
import com.lxy.hsend.common.ErrorCode;
import lombok.Getter;
/**
* 业务异常类
*
* @author lxy
*/
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
// 便捷构造方法
public static BusinessException of(ErrorCode errorCode) {
return new BusinessException(errorCode);
}
public static BusinessException of(ErrorCode errorCode, String message) {
return new BusinessException(errorCode, message);
}
public static BusinessException of(ErrorCode errorCode, String message, Throwable cause) {
return new BusinessException(errorCode, message, cause);
}
// 常用业务异常
public static BusinessException userNotFound() {
return new BusinessException(ErrorCode.USER_NOT_FOUND);
}
public static BusinessException userAlreadyExists() {
return new BusinessException(ErrorCode.USER_ALREADY_EXISTS);
}
public static BusinessException passwordError() {
return new BusinessException(ErrorCode.PASSWORD_ERROR);
}
public static BusinessException sessionNotFound() {
return new BusinessException(ErrorCode.SESSION_NOT_FOUND);
}
public static BusinessException messageNotFound() {
return new BusinessException(ErrorCode.MESSAGE_NOT_FOUND);
}
public static BusinessException fileNotFound() {
return new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
public static BusinessException fileUploadError(String message) {
return new BusinessException(ErrorCode.FILE_UPLOAD_ERROR, message);
}
public static BusinessException aiServiceError(String message) {
return new BusinessException(ErrorCode.AI_SERVICE_ERROR, message);
}
}

View File

@ -0,0 +1,199 @@
package com.lxy.hsend.exception;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.sql.SQLException;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*
* @author lxy
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常处理
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Object>> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.warn("业务异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(e.getErrorCode().getCode(), e.getMessage()));
}
/**
* 参数验证异常处理
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Object>> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数验证异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 绑定异常处理
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Object>> handleBindException(BindException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("参数绑定异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 约束违反异常处理
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Object>> handleConstraintViolationException(ConstraintViolationException e, HttpServletRequest request) {
String message = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
log.warn("约束违反异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 缺少请求参数异常处理
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Object>> handleMissingParamException(MissingServletRequestParameterException e, HttpServletRequest request) {
String message = String.format("缺少必需的请求参数: %s", e.getParameterName());
log.warn("缺少请求参数异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* 参数类型不匹配异常处理
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Object>> handleTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
String message = String.format("参数类型不匹配: %s", e.getName());
log.warn("参数类型不匹配异常: {} - {}", request.getRequestURI(), message);
return ResponseEntity.ok(ApiResponse.validationError(message));
}
/**
* HTTP消息不可读异常处理
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Object>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) {
log.warn("HTTP消息不可读异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.validationError("请求体格式错误"));
}
/**
* 认证异常处理
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiResponse<Object>> handleAuthenticationException(AuthenticationException e, HttpServletRequest request) {
log.warn("认证异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.authError("认证失败,请重新登录"));
}
/**
* 凭据错误异常处理
*/
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiResponse<Object>> handleBadCredentialsException(BadCredentialsException e, HttpServletRequest request) {
log.warn("凭据错误异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.authError("用户名或密码错误"));
}
/**
* 权限不足异常处理
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Object>> handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request) {
log.warn("权限不足异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.forbiddenError("权限不足,无法访问该资源"));
}
/**
* 请求方法不支持异常处理
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Object>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
log.warn("请求方法不支持异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED.getCode(), "请求方法不支持"));
}
/**
* 资源不存在异常处理
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ApiResponse<Object>> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) {
log.warn("资源不存在异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.notFoundError("请求的资源不存在"));
}
/**
* 文件上传大小超限异常处理
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Object>> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e, HttpServletRequest request) {
log.warn("文件上传大小超限异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(ErrorCode.FILE_UPLOAD_ERROR.getCode(), "文件大小超过限制"));
}
/**
* 数据完整性违反异常处理
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Object>> handleDataIntegrityViolationException(DataIntegrityViolationException e, HttpServletRequest request) {
log.error("数据完整性违反异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(ErrorCode.DATA_INTEGRITY_ERROR.getCode(), "数据操作违反完整性约束"));
}
/**
* SQL异常处理
*/
@ExceptionHandler(SQLException.class)
public ResponseEntity<ApiResponse<Object>> handleSQLException(SQLException e, HttpServletRequest request) {
log.error("SQL异常: {} - {}", request.getRequestURI(), e.getMessage());
return ResponseEntity.ok(ApiResponse.error(ErrorCode.DATABASE_ERROR.getCode(), "数据库操作失败"));
}
/**
* 通用异常处理
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Object>> handleGenericException(Exception e, HttpServletRequest request) {
log.error("未知异常: {} - {}", request.getRequestURI(), e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.systemError("系统内部错误,请稍后重试"));
}
}

View File

@ -0,0 +1,231 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.Favorite;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 收藏数据访问层
*/
@Repository
public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
/**
* 根据收藏ID查找收藏
*/
Optional<Favorite> findByFavoriteId(String favoriteId);
/**
* 根据收藏ID查找未删除的收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.favoriteId = :favoriteId AND f.deletedAt IS NULL")
Optional<Favorite> findByFavoriteIdAndNotDeleted(@Param("favoriteId") String favoriteId);
/**
* 根据用户ID和消息ID查找收藏
*/
Optional<Favorite> findByUserIdAndMessageId(Long userId, String messageId);
/**
* 根据用户ID和消息ID查找未删除的收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.messageId = :messageId AND f.deletedAt IS NULL")
Optional<Favorite> findByUserIdAndMessageIdAndNotDeleted(@Param("userId") Long userId,
@Param("messageId") String messageId);
/**
* 根据用户ID查找所有收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 根据用户ID分页查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
Page<Favorite> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据会话ID查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据用户ID和会话ID查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findByUserIdAndSessionIdAndNotDeleted(@Param("userId") Long userId,
@Param("sessionId") String sessionId);
/**
* 根据用户ID和会话ID分页查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
Page<Favorite> findByUserIdAndSessionIdAndNotDeleted(@Param("userId") Long userId,
@Param("sessionId") String sessionId,
Pageable pageable);
/**
* 根据时间范围查找收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.createdAt BETWEEN :startTime AND :endTime AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
List<Favorite> findByUserIdAndCreatedAtBetween(@Param("userId") Long userId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 检查用户是否已收藏某条消息
*/
@Query("SELECT COUNT(f) > 0 FROM Favorite f WHERE f.userId = :userId AND f.messageId = :messageId AND f.deletedAt IS NULL")
boolean existsByUserIdAndMessageIdAndNotDeleted(@Param("userId") Long userId,
@Param("messageId") String messageId);
/**
* 统计用户收藏总数
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计会话收藏总数
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
long countBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据消息ID查找所有收藏记录
*/
@Query("SELECT f FROM Favorite f WHERE f.messageId = :messageId AND f.deletedAt IS NULL")
List<Favorite> findByMessageIdAndNotDeleted(@Param("messageId") String messageId);
/**
* 软删除收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.favoriteId = :favoriteId")
void softDeleteByFavoriteId(@Param("favoriteId") String favoriteId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 根据用户ID和消息ID软删除收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.userId = :userId AND f.messageId = :messageId AND f.deletedAt IS NULL")
void softDeleteByUserIdAndMessageId(@Param("userId") Long userId,
@Param("messageId") String messageId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除会话的所有收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
void softDeleteAllBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除用户的所有收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.userId = :userId AND f.deletedAt IS NULL")
void softDeleteAllByUserId(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除指定消息的所有收藏
*/
@Modifying
@Query("UPDATE Favorite f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.messageId = :messageId AND f.deletedAt IS NULL")
void softDeleteAllByMessageId(@Param("messageId") String messageId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 检查收藏是否属于指定用户
*/
@Query("SELECT COUNT(f) > 0 FROM Favorite f WHERE f.favoriteId = :favoriteId AND f.userId = :userId AND f.deletedAt IS NULL")
boolean existsByFavoriteIdAndUserIdAndNotDeleted(@Param("favoriteId") String favoriteId,
@Param("userId") Long userId);
/**
* 查找用户最近的收藏
*/
@Query("SELECT f FROM Favorite f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.createdAt DESC")
Page<Favorite> findRecentFavoritesByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 统计今日收藏数量
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.createdAt >= :todayStart AND f.deletedAt IS NULL")
long countTodayFavorites(@Param("todayStart") Long todayStart);
/**
* 统计系统总收藏数
*/
@Query("SELECT COUNT(f) FROM Favorite f WHERE f.deletedAt IS NULL")
long countAllNotDeleted();
/**
* 查找收藏数最多的消息
*/
@Query("SELECT f.messageId, COUNT(f) as favoriteCount FROM Favorite f WHERE f.deletedAt IS NULL GROUP BY f.messageId ORDER BY favoriteCount DESC")
Page<Object[]> findMostFavoritedMessages(Pageable pageable);
/**
* 删除会话的所有收藏记录
*/
@Modifying
@Query("DELETE FROM Favorite f WHERE f.sessionId = :sessionId")
void deleteBySessionId(@Param("sessionId") String sessionId);
/**
* 根据关键词搜索用户收藏(搜索消息内容和会话标题)
*/
@Query("""
SELECT f FROM Favorite f
JOIN Message m ON f.messageId = m.messageId
JOIN Session s ON f.sessionId = s.sessionId
WHERE f.userId = :userId
AND f.deletedAt IS NULL
AND m.deletedAt IS NULL
AND s.deletedAt IS NULL
AND (m.content LIKE %:keyword% OR s.title LIKE %:keyword%)
ORDER BY f.createdAt DESC
""")
Page<Favorite> findByUserIdAndKeywordAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 根据关键词和消息角色搜索用户收藏
*/
@Query("""
SELECT f FROM Favorite f
JOIN Message m ON f.messageId = m.messageId
JOIN Session s ON f.sessionId = s.sessionId
WHERE f.userId = :userId
AND f.deletedAt IS NULL
AND m.deletedAt IS NULL
AND s.deletedAt IS NULL
AND m.role = :role
AND (m.content LIKE %:keyword% OR s.title LIKE %:keyword%)
ORDER BY f.createdAt DESC
""")
Page<Favorite> findByUserIdAndKeywordAndRoleAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
@Param("role") String role,
Pageable pageable);
}

View File

@ -0,0 +1,218 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.File;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 文件数据访问层
*/
@Repository
public interface FileRepository extends JpaRepository<File, Long> {
/**
* 根据文件ID查找文件
*/
Optional<File> findByFileId(String fileId);
/**
* 根据文件ID查找未删除的文件
*/
@Query("SELECT f FROM File f WHERE f.fileId = :fileId AND f.deletedAt IS NULL")
Optional<File> findByFileIdAndNotDeleted(@Param("fileId") String fileId);
/**
* 根据用户ID查找所有文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 根据用户ID分页查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
Page<File> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据会话ID查找文件
*/
@Query("SELECT f FROM File f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据用户ID和会话ID查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.sessionId = :sessionId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndSessionIdAndNotDeleted(@Param("userId") Long userId,
@Param("sessionId") String sessionId);
/**
* 根据文件类型查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileType = :fileType AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndFileTypeAndNotDeleted(@Param("userId") Long userId,
@Param("fileType") String fileType);
/**
* 根据文件名搜索文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.filename LIKE %:filename% AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndFilenameLikeAndNotDeleted(@Param("userId") Long userId,
@Param("filename") String filename);
/**
* 根据MIME类型查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.mimeType = :mimeType AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndMimeTypeAndNotDeleted(@Param("userId") Long userId,
@Param("mimeType") String mimeType);
/**
* 根据文件大小范围查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileSize BETWEEN :minSize AND :maxSize AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndFileSizeBetween(@Param("userId") Long userId,
@Param("minSize") Long minSize,
@Param("maxSize") Long maxSize);
/**
* 根据上传时间范围查找文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.uploadTime BETWEEN :startTime AND :endTime AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findByUserIdAndUploadTimeBetween(@Param("userId") Long userId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 检查文件是否存在
*/
@Query("SELECT COUNT(f) > 0 FROM File f WHERE f.storedFilename = :storedFilename AND f.deletedAt IS NULL")
boolean existsByStoredFilenameAndNotDeleted(@Param("storedFilename") String storedFilename);
/**
* 统计用户文件总数
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计用户文件总大小
*/
@Query("SELECT COALESCE(SUM(f.fileSize), 0) FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL")
long sumFileSizeByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计会话文件数量
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
long countBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据文件类型统计数量
*/
@Query("SELECT f.fileType, COUNT(f) FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL GROUP BY f.fileType")
List<Object[]> countByFileTypeAndUserId(@Param("userId") Long userId);
/**
* 查找大文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileSize > :threshold AND f.deletedAt IS NULL ORDER BY f.fileSize DESC")
List<File> findLargeFilesByUserId(@Param("userId") Long userId,
@Param("threshold") Long threshold);
/**
* 查找图片文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.mimeType LIKE 'image/%' AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findImageFilesByUserId(@Param("userId") Long userId);
/**
* 查找文档文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.fileType IN ('pdf', 'doc', 'docx', 'txt', 'md') AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
List<File> findDocumentFilesByUserId(@Param("userId") Long userId);
/**
* 软删除文件
*/
@Modifying
@Query("UPDATE File f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.fileId = :fileId")
void softDeleteByFileId(@Param("fileId") String fileId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除会话的所有文件
*/
@Modifying
@Query("UPDATE File f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.sessionId = :sessionId AND f.deletedAt IS NULL")
void softDeleteAllBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除用户的所有文件
*/
@Modifying
@Query("UPDATE File f SET f.deletedAt = :deletedAt, f.updatedAt = :updateTime WHERE f.userId = :userId AND f.deletedAt IS NULL")
void softDeleteAllByUserId(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 检查文件是否属于指定用户
*/
@Query("SELECT COUNT(f) > 0 FROM File f WHERE f.fileId = :fileId AND f.userId = :userId AND f.deletedAt IS NULL")
boolean existsByFileIdAndUserIdAndNotDeleted(@Param("fileId") String fileId,
@Param("userId") Long userId);
/**
* 查找用户最近上传的文件
*/
@Query("SELECT f FROM File f WHERE f.userId = :userId AND f.deletedAt IS NULL ORDER BY f.uploadTime DESC")
Page<File> findRecentFilesByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 查找旧文件(用于清理)
*/
@Query("SELECT f FROM File f WHERE f.uploadTime < :threshold AND f.deletedAt IS NULL")
List<File> findOldFiles(@Param("threshold") Long threshold);
/**
* 统计今日上传文件数量
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.uploadTime >= :todayStart AND f.deletedAt IS NULL")
long countTodayUploads(@Param("todayStart") Long todayStart);
/**
* 统计今日上传文件总大小
*/
@Query("SELECT COALESCE(SUM(f.fileSize), 0) FROM File f WHERE f.uploadTime >= :todayStart AND f.deletedAt IS NULL")
long sumTodayUploadSize(@Param("todayStart") Long todayStart);
/**
* 统计系统总文件数
*/
@Query("SELECT COUNT(f) FROM File f WHERE f.deletedAt IS NULL")
long countAllNotDeleted();
/**
* 统计系统文件总大小
*/
@Query("SELECT COALESCE(SUM(f.fileSize), 0) FROM File f WHERE f.deletedAt IS NULL")
long sumAllFileSize();
/**
* 根据文件路径查找文件
*/
@Query("SELECT f FROM File f WHERE f.filePath = :filePath AND f.deletedAt IS NULL")
Optional<File> findByFilePathAndNotDeleted(@Param("filePath") String filePath);
}

View File

@ -0,0 +1,199 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.Message;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 消息数据访问层
*/
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
/**
* 根据消息ID查找消息
*/
Optional<Message> findByMessageId(String messageId);
/**
* 根据消息ID查找未删除的消息
*/
@Query("SELECT m FROM Message m WHERE m.messageId = :messageId AND m.deletedAt IS NULL")
Optional<Message> findByMessageIdAndNotDeleted(@Param("messageId") String messageId);
/**
* 根据会话ID查找所有消息
*/
List<Message> findBySessionId(String sessionId);
/**
* 根据会话ID查找未删除的消息按时间排序
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndNotDeletedOrderByTimestamp(@Param("sessionId") String sessionId);
/**
* 根据会话ID分页查找未删除的消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
Page<Message> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId, Pageable pageable);
/**
* 根据用户ID查找消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据角色查找消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.role = :role AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndRoleAndNotDeleted(@Param("sessionId") String sessionId,
@Param("role") String role);
/**
* 查找收藏的消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.isFavorited = true AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findFavoritedBySessionId(@Param("sessionId") String sessionId);
/**
* 根据用户ID查找所有收藏的消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.isFavorited = true AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findFavoritedByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 根据内容关键词搜索消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.content LIKE %:keyword% AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndContentContaining(@Param("sessionId") String sessionId,
@Param("keyword") String keyword);
/**
* 根据AI模型查找消息
*/
@Query("SELECT m FROM Message m WHERE m.modelUsed = :model AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findByModelUsed(@Param("model") String model, Pageable pageable);
/**
* 统计会话消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL")
long countBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 统计用户消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.userId = :userId AND m.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 统计用户收藏消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.userId = :userId AND m.isFavorited = true AND m.deletedAt IS NULL")
long countFavoritedByUserId(@Param("userId") Long userId);
/**
* 获取会话的最后一条消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findLastMessageBySessionId(@Param("sessionId") String sessionId, Pageable pageable);
/**
* 获取会话的第一条消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
Page<Message> findFirstMessageBySessionId(@Param("sessionId") String sessionId, Pageable pageable);
/**
* 查找指定时间范围内的消息
*/
@Query("SELECT m FROM Message m WHERE m.sessionId = :sessionId AND m.timestamp BETWEEN :startTime AND :endTime AND m.deletedAt IS NULL ORDER BY m.timestamp ASC")
List<Message> findBySessionIdAndTimestampBetween(@Param("sessionId") String sessionId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 更新消息收藏状态
*/
@Modifying
@Query("UPDATE Message m SET m.isFavorited = :isFavorited, m.updatedAt = :updateTime WHERE m.messageId = :messageId")
void updateFavoriteStatus(@Param("messageId") String messageId,
@Param("isFavorited") Boolean isFavorited,
@Param("updateTime") Long updateTime);
/**
* 软删除消息
*/
@Modifying
@Query("UPDATE Message m SET m.deletedAt = :deletedAt, m.updatedAt = :updateTime WHERE m.messageId = :messageId")
void softDeleteByMessageId(@Param("messageId") String messageId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 软删除会话的所有消息
*/
@Modifying
@Query("UPDATE Message m SET m.deletedAt = :deletedAt, m.updatedAt = :updateTime WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL")
void softDeleteAllBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量取消收藏
*/
@Modifying
@Query("UPDATE Message m SET m.isFavorited = false, m.updatedAt = :updateTime WHERE m.messageId IN :messageIds")
void unfavoriteMessages(@Param("messageIds") List<String> messageIds,
@Param("updateTime") Long updateTime);
/**
* 检查消息是否属于指定用户
*/
@Query("SELECT COUNT(m) > 0 FROM Message m WHERE m.messageId = :messageId AND m.userId = :userId AND m.deletedAt IS NULL")
boolean existsByMessageIdAndUserIdAndNotDeleted(@Param("messageId") String messageId,
@Param("userId") Long userId);
/**
* 查找使用深度思考的消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.deepThinking = true AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findDeepThinkingMessages(@Param("userId") Long userId, Pageable pageable);
/**
* 查找使用联网搜索的消息
*/
@Query("SELECT m FROM Message m WHERE m.userId = :userId AND m.webSearch = true AND m.deletedAt IS NULL ORDER BY m.timestamp DESC")
Page<Message> findWebSearchMessages(@Param("userId") Long userId, Pageable pageable);
/**
* 统计各模型使用次数
*/
@Query("SELECT m.modelUsed, COUNT(m) FROM Message m WHERE m.modelUsed IS NOT NULL AND m.deletedAt IS NULL GROUP BY m.modelUsed")
List<Object[]> countByModelUsage();
/**
* 统计今日消息数量
*/
@Query("SELECT COUNT(m) FROM Message m WHERE m.timestamp >= :todayStart AND m.deletedAt IS NULL")
long countTodayMessages(@Param("todayStart") Long todayStart);
/**
* 软删除会话的所有消息
*/
@Modifying
@Query("UPDATE Message m SET m.deletedAt = :deletedAt, m.updatedAt = :updateTime WHERE m.sessionId = :sessionId AND m.deletedAt IS NULL")
void softDeleteBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
}

View File

@ -0,0 +1,162 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.Session;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 会话数据访问层
*/
@Repository
public interface SessionRepository extends JpaRepository<Session, Long> {
/**
* 根据会话ID查找会话
*/
Optional<Session> findBySessionId(String sessionId);
/**
* 根据会话ID查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.sessionId = :sessionId AND s.deletedAt IS NULL")
Optional<Session> findBySessionIdAndNotDeleted(@Param("sessionId") String sessionId);
/**
* 根据用户ID查找所有会话
*/
List<Session> findByUserId(Long userId);
/**
* 根据用户ID查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC NULLS LAST, s.createdAt DESC")
List<Session> findByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 根据用户ID分页查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL")
Page<Session> findByUserIdAndNotDeleted(@Param("userId") Long userId, Pageable pageable);
/**
* 根据标题关键词搜索会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.title LIKE %:keyword% AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC NULLS LAST")
Page<Session> findByUserIdAndTitleContainingAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 根据关键词搜索会话(搜索标题和消息内容)
*/
@Query("SELECT DISTINCT s FROM Session s LEFT JOIN Message m ON s.sessionId = m.sessionId " +
"WHERE s.userId = :userId AND s.deletedAt IS NULL " +
"AND (s.title LIKE %:keyword% OR m.content LIKE %:keyword%) " +
"ORDER BY s.lastMessageTime DESC NULLS LAST, s.createdAt DESC")
Page<Session> findByUserIdAndKeywordAndNotDeleted(@Param("userId") Long userId,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 根据会话ID和用户ID查找未删除的会话
*/
@Query("SELECT s FROM Session s WHERE s.sessionId = :sessionId AND s.userId = :userId AND s.deletedAt IS NULL")
Optional<Session> findBySessionIdAndUserIdAndNotDeleted(@Param("sessionId") String sessionId,
@Param("userId") Long userId);
/**
* 根据用户ID和时间范围查找会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.createdAt BETWEEN :startTime AND :endTime AND s.deletedAt IS NULL")
List<Session> findByUserIdAndCreatedAtBetween(@Param("userId") Long userId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 统计用户的会话总数
*/
@Query("SELECT COUNT(s) FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL")
long countByUserIdAndNotDeleted(@Param("userId") Long userId);
/**
* 查找用户最近的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC NULLS LAST, s.createdAt DESC")
Page<Session> findRecentSessionsByUserId(@Param("userId") Long userId, Pageable pageable);
/**
* 查找有消息的会话
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND s.messageCount > 0 AND s.deletedAt IS NULL ORDER BY s.lastMessageTime DESC")
List<Session> findSessionsWithMessages(@Param("userId") Long userId);
/**
* 查找空会话(没有消息的会话)
*/
@Query("SELECT s FROM Session s WHERE s.userId = :userId AND (s.messageCount = 0 OR s.messageCount IS NULL) AND s.deletedAt IS NULL")
List<Session> findEmptySessions(@Param("userId") Long userId);
/**
* 更新会话的消息统计
*/
@Modifying
@Query("UPDATE Session s SET s.messageCount = :messageCount, s.lastMessageTime = :lastMessageTime, s.updatedAt = :updateTime WHERE s.sessionId = :sessionId")
void updateMessageStatistics(@Param("sessionId") String sessionId,
@Param("messageCount") Integer messageCount,
@Param("lastMessageTime") Long lastMessageTime,
@Param("updateTime") Long updateTime);
/**
* 更新会话标题
*/
@Modifying
@Query("UPDATE Session s SET s.title = :title, s.updatedAt = :updateTime WHERE s.sessionId = :sessionId")
void updateTitle(@Param("sessionId") String sessionId,
@Param("title") String title,
@Param("updateTime") Long updateTime);
/**
* 软删除会话
*/
@Modifying
@Query("UPDATE Session s SET s.deletedAt = :deletedAt, s.updatedAt = :updateTime WHERE s.sessionId = :sessionId")
void softDeleteBySessionId(@Param("sessionId") String sessionId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量软删除用户的所有会话
*/
@Modifying
@Query("UPDATE Session s SET s.deletedAt = :deletedAt, s.updatedAt = :updateTime WHERE s.userId = :userId AND s.deletedAt IS NULL")
void softDeleteAllByUserId(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 检查会话是否属于指定用户
*/
@Query("SELECT COUNT(s) > 0 FROM Session s WHERE s.sessionId = :sessionId AND s.userId = :userId AND s.deletedAt IS NULL")
boolean existsBySessionIdAndUserIdAndNotDeleted(@Param("sessionId") String sessionId,
@Param("userId") Long userId);
/**
* 查找长时间未活动的会话
*/
@Query("SELECT s FROM Session s WHERE s.lastMessageTime < :threshold AND s.deletedAt IS NULL")
List<Session> findInactiveSessions(@Param("threshold") Long threshold);
/**
* 统计系统总会话数
*/
@Query("SELECT COUNT(s) FROM Session s WHERE s.deletedAt IS NULL")
long countAllNotDeleted();
}

View File

@ -0,0 +1,132 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 用户数据访问层
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 根据邮箱查找用户
*/
Optional<User> findByEmail(String email);
/**
* 根据邮箱查找未删除的用户
*/
@Query("SELECT u FROM User u WHERE u.email = :email AND u.deletedAt IS NULL")
Optional<User> findByEmailAndNotDeleted(@Param("email") String email);
/**
* 根据邮箱查找未删除的用户Spring Data方法命名
*/
Optional<User> findByEmailAndDeletedAtIsNull(String email);
/**
* 根据ID查找未删除的用户
*/
Optional<User> findByIdAndDeletedAtIsNull(Long id);
/**
* 检查邮箱是否存在(未删除)
*/
boolean existsByEmailAndDeletedAtIsNull(String email);
/**
* 根据昵称查找用户
*/
List<User> findByNicknameContaining(String nickname);
/**
* 根据状态查找用户
*/
List<User> findByStatus(Byte status);
/**
* 查找未删除的用户
*/
@Query("SELECT u FROM User u WHERE u.deletedAt IS NULL")
List<User> findAllNotDeleted();
/**
* 分页查找未删除的用户
*/
@Query("SELECT u FROM User u WHERE u.deletedAt IS NULL")
Page<User> findAllNotDeleted(Pageable pageable);
/**
* 检查邮箱是否已存在
*/
boolean existsByEmail(String email);
/**
* 检查邮箱是否已存在(排除指定用户)
*/
@Query("SELECT COUNT(u) > 0 FROM User u WHERE u.email = :email AND u.id != :userId AND u.deletedAt IS NULL")
boolean existsByEmailAndNotUserId(@Param("email") String email, @Param("userId") Long userId);
/**
* 根据状态和创建时间范围查找用户
*/
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt BETWEEN :startTime AND :endTime AND u.deletedAt IS NULL")
List<User> findByStatusAndCreatedAtBetween(@Param("status") Byte status,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
/**
* 统计正常状态的用户数量
*/
@Query("SELECT COUNT(u) FROM User u WHERE u.status = 1 AND u.deletedAt IS NULL")
long countActiveUsers();
/**
* 统计今日注册用户数量
*/
@Query("SELECT COUNT(u) FROM User u WHERE u.createdAt >= :todayStart AND u.deletedAt IS NULL")
long countTodayRegistrations(@Param("todayStart") Long todayStart);
/**
* 更新用户最后登录时间
*/
@Modifying
@Query("UPDATE User u SET u.lastLoginTime = :loginTime, u.updatedAt = :updateTime WHERE u.id = :userId")
void updateLastLoginTime(@Param("userId") Long userId,
@Param("loginTime") Long loginTime,
@Param("updateTime") Long updateTime);
/**
* 软删除用户
*/
@Modifying
@Query("UPDATE User u SET u.deletedAt = :deletedAt, u.status = 3, u.updatedAt = :updateTime WHERE u.id = :userId")
void softDeleteUser(@Param("userId") Long userId,
@Param("deletedAt") Long deletedAt,
@Param("updateTime") Long updateTime);
/**
* 批量更新用户状态
*/
@Modifying
@Query("UPDATE User u SET u.status = :status, u.updatedAt = :updateTime WHERE u.id IN :userIds")
void updateStatusByIds(@Param("userIds") List<Long> userIds,
@Param("status") Byte status,
@Param("updateTime") Long updateTime);
/**
* 查找长期未登录的用户
*/
@Query("SELECT u FROM User u WHERE u.lastLoginTime < :threshold AND u.deletedAt IS NULL")
List<User> findInactiveUsers(@Param("threshold") Long threshold);
}

View File

@ -0,0 +1,243 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.favorite.AddFavoriteRequest;
import com.lxy.hsend.dto.favorite.FavoriteListRequest;
import com.lxy.hsend.dto.favorite.FavoriteResponse;
import com.lxy.hsend.dto.favorite.RemoveFavoriteRequest;
import com.lxy.hsend.entity.Favorite;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.FavoriteRepository;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 收藏服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FavoriteService {
private final FavoriteRepository favoriteRepository;
private final MessageRepository messageRepository;
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
/**
* 用户收藏数量上限
*/
private static final long MAX_FAVORITES_PER_USER = 1000;
/**
* 添加收藏
*/
@Transactional
public ApiResponse<FavoriteResponse> addFavorite(Long userId, AddFavoriteRequest request) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 验证消息存在且属于用户
Message message = messageRepository.findByMessageIdAndNotDeleted(request.getMessageId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "消息不存在"));
if (!message.getUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN_ERROR, "无权限收藏此消息");
}
// 3. 验证会话存在且属于用户
Session session = sessionRepository.findBySessionIdAndUserIdAndNotDeleted(request.getSessionId(), userId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"));
// 4. 检查是否已经收藏
if (favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(userId, request.getMessageId())) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "该消息已经收藏过了");
}
// 5. 检查用户收藏数量是否超过限制
long userFavoriteCount = favoriteRepository.countByUserIdAndNotDeleted(userId);
if (userFavoriteCount >= MAX_FAVORITES_PER_USER) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "收藏数量已达上限(" + MAX_FAVORITES_PER_USER + "条)");
}
// 6. 创建收藏记录
Favorite favorite = createFavorite(userId, request.getMessageId(), request.getSessionId());
Favorite savedFavorite = favoriteRepository.save(favorite);
log.info("用户添加收藏成功: userId={}, messageId={}, favoriteId={}",
userId, request.getMessageId(), savedFavorite.getFavoriteId());
// 7. 转换为响应DTO
FavoriteResponse response = convertToFavoriteResponse(savedFavorite, message, session);
return ApiResponse.success(response, "收藏成功");
}
/**
* 取消收藏
*/
@Transactional
public ApiResponse<Void> removeFavorite(Long userId, RemoveFavoriteRequest request) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 查找收藏记录
Favorite favorite = favoriteRepository.findByUserIdAndMessageIdAndNotDeleted(userId, request.getMessageId())
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "收藏记录不存在"));
// 3. 软删除收藏记录
Long currentTime = TimeUtil.getCurrentTimestamp();
favoriteRepository.softDeleteByUserIdAndMessageId(userId, request.getMessageId(), currentTime, currentTime);
log.info("用户取消收藏成功: userId={}, messageId={}, favoriteId={}",
userId, request.getMessageId(), favorite.getFavoriteId());
return ApiResponse.success(null, "取消收藏成功");
}
/**
* 获取收藏列表
*/
public ApiResponse<PageUtil.PageResponse<FavoriteResponse>> getFavoriteList(Long userId, FavoriteListRequest request) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 构建分页参数
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize());
// 3. 查询收藏列表
Page<Favorite> favoritePage;
if (StringUtils.hasText(request.getKeyword())) {
// 有关键词搜索
if ("all".equals(request.getMessageRole())) {
favoritePage = favoriteRepository.findByUserIdAndKeywordAndNotDeleted(userId, request.getKeyword(), pageable);
} else {
favoritePage = favoriteRepository.findByUserIdAndKeywordAndRoleAndNotDeleted(
userId, request.getKeyword(), request.getMessageRole(), pageable);
}
} else {
// 无关键词,查询所有收藏
favoritePage = favoriteRepository.findByUserIdAndNotDeleted(userId, pageable);
}
// 4. 转换为响应DTO列表
List<FavoriteResponse> favoriteResponses = favoritePage.getContent().stream()
.map(this::convertToFavoriteResponseWithLookup)
.collect(Collectors.toList());
// 5. 构建分页响应
PageUtil.PageResponse<FavoriteResponse> pageResponse = new PageUtil.PageResponse<>(
favoriteResponses,
request.getPage(),
request.getPageSize(),
favoritePage.getTotalElements(),
favoritePage.getTotalPages(),
favoritePage.isFirst(),
favoritePage.isLast(),
favoritePage.hasNext(),
favoritePage.hasPrevious()
);
return ApiResponse.success(pageResponse);
}
/**
* 检查消息是否被收藏
*/
public ApiResponse<Boolean> isFavorited(Long userId, String messageId) {
boolean isFavorited = favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(userId, messageId);
return ApiResponse.success(isFavorited);
}
/**
* 获取用户收藏数量
*/
public ApiResponse<Long> getUserFavoriteCount(Long userId) {
// 1. 验证用户存在
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 统计收藏数量
long count = favoriteRepository.countByUserIdAndNotDeleted(userId);
return ApiResponse.success(count);
}
/**
* 创建收藏对象
*/
private Favorite createFavorite(Long userId, String messageId, String sessionId) {
Favorite favorite = new Favorite();
favorite.setFavoriteId(UUID.randomUUID().toString());
favorite.setUserId(userId);
favorite.setMessageId(messageId);
favorite.setSessionId(sessionId);
Long currentTime = TimeUtil.getCurrentTimestamp();
favorite.setCreatedAt(currentTime);
favorite.setUpdatedAt(currentTime);
return favorite;
}
/**
* 转换为收藏响应DTO使用已有的消息和会话对象
*/
private FavoriteResponse convertToFavoriteResponse(Favorite favorite, Message message, Session session) {
return FavoriteResponse.builder()
.favoriteId(favorite.getFavoriteId())
.messageId(favorite.getMessageId())
.sessionId(favorite.getSessionId())
.sessionTitle(session.getTitle())
.messageContent(message.getContent())
.messageRole(message.getRole())
.messageCreatedAt(TimeUtil.timestampToLocalDateTime(message.getCreatedAt()))
.favoritedAt(TimeUtil.timestampToLocalDateTime(favorite.getCreatedAt()))
.build();
}
/**
* 转换为收藏响应DTO需要查询消息和会话信息
*/
private FavoriteResponse convertToFavoriteResponseWithLookup(Favorite favorite) {
// 查询消息信息
Message message = messageRepository.findByMessageIdAndNotDeleted(favorite.getMessageId())
.orElse(null);
// 查询会话信息
Session session = sessionRepository.findBySessionIdAndNotDeleted(favorite.getSessionId())
.orElse(null);
return FavoriteResponse.builder()
.favoriteId(favorite.getFavoriteId())
.messageId(favorite.getMessageId())
.sessionId(favorite.getSessionId())
.sessionTitle(session != null ? session.getTitle() : "未知会话")
.messageContent(message != null ? message.getContent() : "消息已删除")
.messageRole(message != null ? message.getRole() : "unknown")
.messageCreatedAt(message != null ? TimeUtil.timestampToLocalDateTime(message.getCreatedAt()) : null)
.favoritedAt(TimeUtil.timestampToLocalDateTime(favorite.getCreatedAt()))
.build();
}
}

View File

@ -0,0 +1,244 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.message.MessageListRequest;
import com.lxy.hsend.dto.message.MessageResponse;
import com.lxy.hsend.dto.message.SendMessageRequest;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 消息服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageRepository messageRepository;
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
/**
* 发送消息
*/
@Transactional
public ApiResponse<MessageResponse> sendMessage(Long userId, SendMessageRequest request) {
// 1. 验证用户
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 验证会话
Session session = sessionRepository.findBySessionIdAndUserIdAndNotDeleted(request.getSessionId(), userId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"));
// 3. 内容过滤和验证
String filteredContent = filterMessageContent(request.getContent());
if (filteredContent.trim().isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "消息内容不能为空");
}
// 4. 创建用户消息
Message userMessage = createMessage(
userId,
request.getSessionId(),
"user",
filteredContent,
request.getDeepThinking(),
request.getWebSearch()
);
// 5. 保存用户消息
Message savedUserMessage = messageRepository.save(userMessage);
log.info("用户消息已保存: userId={}, sessionId={}, messageId={}",
userId, request.getSessionId(), savedUserMessage.getMessageId());
// 6. 更新会话统计
updateSessionStatistics(session, savedUserMessage);
// 7. 预留AI响应生成暂时返回占位响应
// TODO: 集成AI服务生成响应
Message aiMessage = createAIPlaceholderMessage(userId, request.getSessionId(), savedUserMessage);
Message savedAiMessage = messageRepository.save(aiMessage);
// 8. 再次更新会话统计
updateSessionStatistics(session, savedAiMessage);
// 9. 返回AI消息响应
MessageResponse response = convertToMessageResponse(savedAiMessage);
return ApiResponse.success(response);
}
/**
* 获取消息列表
*/
public ApiResponse<PageUtil.PageResponse<MessageResponse>> getMessages(Long userId, MessageListRequest request) {
// 1. 验证用户
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 2. 验证会话权限
Session session = sessionRepository.findBySessionIdAndUserIdAndNotDeleted(request.getSessionId(), userId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在"));
// 3. 构建分页参数
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize());
// 4. 查询消息
Page<Message> messagePage;
if ("all".equals(request.getRole())) {
messagePage = messageRepository.findBySessionIdAndNotDeleted(request.getSessionId(), pageable);
} else {
// 如果指定了角色过滤需要添加对应的repository方法
messagePage = messageRepository.findBySessionIdAndNotDeleted(request.getSessionId(), pageable);
}
// 5. 转换为响应DTO
List<MessageResponse> messageResponses = messagePage.getContent().stream()
.filter(message -> "all".equals(request.getRole()) || request.getRole().equals(message.getRole()))
.map(this::convertToMessageResponse)
.collect(Collectors.toList());
// 6. 构建分页响应
PageUtil.PageResponse<MessageResponse> pageResponse = new PageUtil.PageResponse<>(
messageResponses,
request.getPage(),
request.getPageSize(),
messagePage.getTotalElements(),
messagePage.getTotalPages(),
messagePage.isFirst(),
messagePage.isLast(),
messagePage.hasNext(),
messagePage.hasPrevious()
);
return ApiResponse.success(pageResponse);
}
/**
* 根据消息ID获取消息详情
*/
public ApiResponse<MessageResponse> getMessageById(Long userId, String messageId) {
// 1. 验证消息存在且属于用户
if (!messageRepository.existsByMessageIdAndUserIdAndNotDeleted(messageId, userId)) {
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "消息不存在");
}
// 2. 获取消息
Message message = messageRepository.findByMessageIdAndNotDeleted(messageId)
.orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "消息不存在"));
// 3. 转换为响应DTO
MessageResponse response = convertToMessageResponse(message);
return ApiResponse.success(response);
}
/**
* 创建消息对象
*/
private Message createMessage(Long userId, String sessionId, String role, String content,
Boolean deepThinking, Boolean webSearch) {
Message message = new Message();
message.setMessageId(UUID.randomUUID().toString());
message.setSessionId(sessionId);
message.setUserId(userId);
message.setRole(role);
message.setContent(content);
// 消息状态通过其他字段表示无需单独status字段
message.setDeepThinking(deepThinking != null ? deepThinking : false);
message.setWebSearch(webSearch != null ? webSearch : false);
message.setIsFavorited(false);
Long currentTime = TimeUtil.getCurrentTimestamp();
message.setTimestamp(currentTime);
message.setCreatedAt(currentTime);
message.setUpdatedAt(currentTime);
return message;
}
/**
* 创建AI占位响应消息
*/
private Message createAIPlaceholderMessage(Long userId, String sessionId, Message userMessage) {
String aiContent = generateAIPlaceholderResponse(userMessage.getContent());
return createMessage(
userId,
sessionId,
"assistant",
aiContent,
userMessage.getDeepThinking(),
userMessage.getWebSearch()
);
}
/**
* 生成AI占位响应后续将被实际AI服务替换
*/
private String generateAIPlaceholderResponse(String userContent) {
return "感谢您的提问。这是一个临时响应实际的AI服务将在后续版本中集成。您的问题是" + userContent;
}
/**
* 消息内容过滤
*/
private String filterMessageContent(String content) {
if (content == null) {
return "";
}
// 基础过滤:去除首尾空格,限制长度
String filtered = content.trim();
// TODO: 添加更多过滤规则(敏感词、恶意内容等)
return filtered;
}
/**
* 更新会话统计
*/
private void updateSessionStatistics(Session session, Message message) {
session.setMessageCount(session.getMessageCount() + 1);
session.setLastMessageTime(message.getTimestamp());
session.setUpdatedAt(TimeUtil.getCurrentTimestamp());
sessionRepository.save(session);
}
/**
* 转换为消息响应DTO
*/
private MessageResponse convertToMessageResponse(Message message) {
return MessageResponse.builder()
.messageId(message.getMessageId())
.sessionId(message.getSessionId())
.role(message.getRole())
.content(message.getContent())
.status("completed")
.deepThinking(message.getDeepThinking())
.webSearch(message.getWebSearch())
.createdAt(TimeUtil.timestampToLocalDateTime(message.getCreatedAt()))
.updatedAt(TimeUtil.timestampToLocalDateTime(message.getUpdatedAt()))
.build();
}
}

View File

@ -0,0 +1,307 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.session.*;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.FavoriteRepository;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.util.StringUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 会话服务类
*
* @author lxy
*/
@Slf4j
@Service
@Transactional
public class SessionService {
private final SessionRepository sessionRepository;
private final MessageRepository messageRepository;
private final FavoriteRepository favoriteRepository;
@Autowired
public SessionService(SessionRepository sessionRepository,
MessageRepository messageRepository,
FavoriteRepository favoriteRepository) {
this.sessionRepository = sessionRepository;
this.messageRepository = messageRepository;
this.favoriteRepository = favoriteRepository;
}
/**
* 创建会话
*/
public SessionDetailResponse createSession(Long userId, CreateSessionRequest request) {
log.info("创建会话: userId={}, title={}", userId, request.getTitle());
// 创建会话实体
Session session = new Session();
session.setSessionId(UUID.randomUUID().toString());
session.setUserId(userId);
session.setTitle(request.getTitle());
session.setMessageCount(0);
long now = TimeUtil.getCurrentTimestamp();
session.setCreatedAt(now);
session.setUpdatedAt(now);
// 保存会话
Session savedSession = sessionRepository.save(session);
// 如果有初始消息,创建第一条消息
if (StringUtil.isNotEmpty(request.getInitialMessage())) {
Message initialMessage = new Message();
initialMessage.setSessionId(savedSession.getSessionId());
initialMessage.setUserId(userId);
initialMessage.setContent(request.getInitialMessage());
initialMessage.setRole("user");
initialMessage.setCreatedAt(now);
initialMessage.setUpdatedAt(now);
messageRepository.save(initialMessage);
// 更新会话统计
updateSessionStatistics(savedSession.getSessionId());
}
log.info("会话创建成功: sessionId={}", savedSession.getSessionId());
return convertToSessionDetailResponse(savedSession);
}
/**
* 获取会话列表(支持搜索和分页)
*/
@Transactional(readOnly = true)
public Page<SessionListResponse> getSessionList(Long userId, SessionSearchRequest request) {
log.debug("获取会话列表: userId={}, keyword={}, page={}, pageSize={}",
userId, request.getKeyword(), request.getPage(), request.getPageSize());
// 创建分页对象
Sort sort = createSort(request.getValidSortBy(), request.isDescOrder());
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getPageSize(), sort);
Page<Session> sessionPage;
// 根据是否有关键词选择不同的查询方法
if (request.hasKeyword()) {
sessionPage = sessionRepository.findByUserIdAndKeywordAndNotDeleted(
userId, request.getCleanKeyword(), pageable);
} else {
sessionPage = sessionRepository.findByUserIdAndNotDeleted(userId, pageable);
}
// 转换为响应DTO
return sessionPage.map(this::convertToSessionListResponse);
}
/**
* 获取会话详情
*/
@Transactional(readOnly = true)
public SessionDetailResponse getSessionDetail(Long userId, String sessionId) {
log.debug("获取会话详情: userId={}, sessionId={}", userId, sessionId);
Session session = findSessionByIdAndUserId(sessionId, userId);
return convertToSessionDetailResponse(session);
}
/**
* 更新会话标题
*/
public SessionDetailResponse updateSession(Long userId, String sessionId, UpdateSessionRequest request) {
log.info("更新会话: userId={}, sessionId={}, newTitle={}", userId, sessionId, request.getTitle());
// 验证会话存在和权限
Session session = findSessionByIdAndUserId(sessionId, userId);
if (StringUtil.isNotEmpty(request.getTitle()) && !StringUtil.equals(session.getTitle(), request.getTitle())) {
long now = TimeUtil.getCurrentTimestamp();
sessionRepository.updateTitle(sessionId, request.getTitle(), now);
// 更新本地对象
session.setTitle(request.getTitle());
session.setUpdatedAt(now);
log.info("会话标题更新成功: sessionId={}", sessionId);
}
return convertToSessionDetailResponse(session);
}
/**
* 删除会话(软删除,级联删除消息和收藏)
*/
public void deleteSession(Long userId, String sessionId) {
log.info("删除会话: userId={}, sessionId={}", userId, sessionId);
// 验证会话存在和权限
findSessionByIdAndUserId(sessionId, userId);
long now = TimeUtil.getCurrentTimestamp();
// 软删除会话
sessionRepository.softDeleteBySessionId(sessionId, now, now);
// 软删除关联的消息
messageRepository.softDeleteBySessionId(sessionId, now, now);
// 删除收藏记录
favoriteRepository.deleteBySessionId(sessionId);
log.info("会话删除成功: sessionId={}", sessionId);
}
/**
* 清空会话(只删除消息,保留会话)
*/
public void clearSession(Long userId, String sessionId) {
log.info("清空会话: userId={}, sessionId={}", userId, sessionId);
// 验证会话存在和权限
findSessionByIdAndUserId(sessionId, userId);
long now = TimeUtil.getCurrentTimestamp();
// 软删除关联的消息
messageRepository.softDeleteBySessionId(sessionId, now, now);
// 删除收藏记录
favoriteRepository.deleteBySessionId(sessionId);
// 重置会话统计
sessionRepository.updateMessageStatistics(sessionId, 0, null, now);
log.info("会话清空成功: sessionId={}", sessionId);
}
/**
* 更新会话统计信息
*/
public void updateSessionStatistics(String sessionId) {
log.debug("更新会话统计: sessionId={}", sessionId);
// 获取消息统计
long messageCount = messageRepository.countBySessionIdAndNotDeleted(sessionId);
// 获取最后一条消息的时间
Long lastMessageTime = null;
if (messageCount > 0) {
Page<Message> lastMessagePage = messageRepository.findBySessionIdAndNotDeleted(
sessionId, PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createdAt")));
if (!lastMessagePage.isEmpty()) {
lastMessageTime = lastMessagePage.getContent().get(0).getCreatedAt();
}
}
// 更新统计信息
long now = TimeUtil.getCurrentTimestamp();
sessionRepository.updateMessageStatistics(sessionId, (int) messageCount, lastMessageTime, now);
log.debug("会话统计更新完成: sessionId={}, messageCount={}, lastMessageTime={}",
sessionId, messageCount, lastMessageTime);
}
/**
* 验证会话权限
*/
@Transactional(readOnly = true)
public boolean hasSessionPermission(Long userId, String sessionId) {
return sessionRepository.existsBySessionIdAndUserIdAndNotDeleted(sessionId, userId);
}
/**
* 查找用户的会话并验证权限
*/
private Session findSessionByIdAndUserId(String sessionId, Long userId) {
return sessionRepository.findBySessionIdAndUserIdAndNotDeleted(sessionId, userId)
.orElseThrow(() -> BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "会话不存在或无访问权限"));
}
/**
* 创建排序对象
*/
private Sort createSort(String sortBy, boolean isDesc) {
Sort.Direction direction = isDesc ? Sort.Direction.DESC : Sort.Direction.ASC;
switch (sortBy) {
case "created_at":
return Sort.by(direction, "createdAt");
case "updated_at":
return Sort.by(direction, "updatedAt");
case "last_message_time":
default:
// 对于 last_message_time需要处理 NULL 值
if (isDesc) {
return Sort.by(Sort.Direction.DESC, "lastMessageTime")
.and(Sort.by(Sort.Direction.DESC, "createdAt"));
} else {
return Sort.by(Sort.Direction.ASC, "lastMessageTime")
.and(Sort.by(Sort.Direction.ASC, "createdAt"));
}
}
}
/**
* 转换为会话列表响应DTO
*/
private SessionListResponse convertToSessionListResponse(Session session) {
SessionListResponse response = new SessionListResponse();
response.setSessionId(session.getSessionId());
response.setTitle(session.getTitle());
response.setMessageCount(session.getMessageCount() != null ? session.getMessageCount() : 0);
response.setLastMessageTime(session.getLastMessageTime());
response.setCreatedAt(session.getCreatedAt());
response.setUpdatedAt(session.getUpdatedAt());
// 获取最后一条消息的内容摘要
if (session.getMessageCount() != null && session.getMessageCount() > 0) {
try {
Page<Message> lastMessagePage = messageRepository.findBySessionIdAndNotDeleted(
session.getSessionId(), PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createdAt")));
if (!lastMessagePage.isEmpty()) {
Message lastMessage = lastMessagePage.getContent().get(0);
response.setLastMessageContent(lastMessage.getContent());
}
} catch (Exception e) {
log.warn("获取最后消息内容失败: sessionId={}, error={}", session.getSessionId(), e.getMessage());
}
}
return response;
}
/**
* 转换为会话详情响应DTO
*/
private SessionDetailResponse convertToSessionDetailResponse(Session session) {
SessionDetailResponse response = new SessionDetailResponse();
response.setSessionId(session.getSessionId());
response.setUserId(session.getUserId());
response.setTitle(session.getTitle());
response.setMessageCount(session.getMessageCount() != null ? session.getMessageCount() : 0);
response.setLastMessageTime(session.getLastMessageTime());
response.setCreatedAt(session.getCreatedAt());
response.setUpdatedAt(session.getUpdatedAt());
return response;
}
}

View File

@ -0,0 +1,287 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.Constants;
import com.lxy.hsend.common.ErrorCode;
import com.lxy.hsend.dto.auth.LoginRequest;
import com.lxy.hsend.dto.auth.LoginResponse;
import com.lxy.hsend.dto.auth.RegisterRequest;
import com.lxy.hsend.dto.user.UpdateUserRequest;
import com.lxy.hsend.dto.user.UserInfoResponse;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.exception.BusinessException;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.CryptoUtil;
import com.lxy.hsend.util.JwtUtil;
import com.lxy.hsend.util.StringUtil;
import com.lxy.hsend.util.TimeUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
/**
* 用户服务类
*
* @author lxy
*/
@Slf4j
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public UserService(UserRepository userRepository, JwtUtil jwtUtil, RedisTemplate<String, Object> redisTemplate) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
this.redisTemplate = redisTemplate;
}
/**
* 用户注册
*/
public UserInfoResponse register(RegisterRequest request) {
log.info("用户注册请求: {}", request.getEmail());
// 验证确认密码
if (!StringUtil.equals(request.getPassword(), request.getConfirmPassword())) {
throw BusinessException.of(ErrorCode.VALIDATION_ERROR, "两次输入的密码不一致");
}
// 转换邮箱为小写
String email = request.getEmail().toLowerCase();
// 检查邮箱是否已存在
if (userRepository.existsByEmailAndDeletedAtIsNull(email)) {
throw BusinessException.of(ErrorCode.USER_ALREADY_EXISTS, "该邮箱已被注册");
}
// 创建用户
User user = new User();
user.setEmail(email);
user.setPasswordHash(CryptoUtil.encryptPassword(request.getPassword()));
user.setNickname(request.getNickname());
user.setAvatarUrl(StringUtil.isEmpty(request.getAvatarUrl()) ? getDefaultAvatarUrl() : request.getAvatarUrl());
user.setStatus(Constants.USER_STATUS_NORMAL);
long now = TimeUtil.getCurrentTimestamp();
user.setCreatedAt(now);
user.setUpdatedAt(now);
// 保存用户
User savedUser = userRepository.save(user);
log.info("用户注册成功: userId={}, email={}", savedUser.getId(), savedUser.getEmail());
return convertToUserInfoResponse(savedUser);
}
/**
* 用户登录
*/
public LoginResponse login(LoginRequest request) {
String email = request.getEmail().toLowerCase();
log.info("用户登录请求: {}", email);
// 检查登录失败次数
checkLoginAttempts(email);
// 查找用户
User user = userRepository.findByEmailAndDeletedAtIsNull(email)
.orElseThrow(() -> BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在"));
// 检查用户状态
checkUserStatus(user);
// 验证密码
if (!CryptoUtil.verifyPassword(request.getPassword(), user.getPasswordHash())) {
// 记录登录失败
recordLoginFailure(email);
throw BusinessException.of(ErrorCode.PASSWORD_ERROR, "密码错误");
}
// 清除登录失败记录
clearLoginFailures(email);
// 更新最后登录时间
long now = TimeUtil.getCurrentTimestamp();
user.setLastLoginTime(now);
user.setUpdatedAt(now);
userRepository.save(user);
// 生成Token
String accessToken = jwtUtil.generateToken(user.getId(), user.getEmail());
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), user.getEmail());
// 缓存用户信息到Redis
cacheUserInfo(user);
log.info("用户登录成功: userId={}, email={}", user.getId(), user.getEmail());
UserInfoResponse userInfo = convertToUserInfoResponse(user);
return new LoginResponse(accessToken, refreshToken, Constants.JWT_EXPIRATION_TIME / 1000, userInfo);
}
/**
* 用户登出
*/
public void logout(Long userId, String token) {
log.info("用户登出: userId={}", userId);
// 将token加入黑名单
jwtUtil.blacklistToken(token);
// 清除Redis中的用户信息缓存
String cacheKey = Constants.REDIS_USER_INFO_PREFIX + userId;
redisTemplate.delete(cacheKey);
log.info("用户登出成功: userId={}", userId);
}
/**
* 根据ID获取用户信息
*/
@Transactional(readOnly = true)
public UserInfoResponse getUserById(Long userId) {
// 先尝试从Redis获取
String cacheKey = Constants.REDIS_USER_INFO_PREFIX + userId;
UserInfoResponse cachedUser = (UserInfoResponse) redisTemplate.opsForValue().get(cacheKey);
if (cachedUser != null) {
return cachedUser;
}
// 从数据库获取
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在"));
UserInfoResponse userInfo = convertToUserInfoResponse(user);
// 缓存到Redis
redisTemplate.opsForValue().set(cacheKey, userInfo, Constants.REDIS_USER_INFO_EXPIRE_TIME, TimeUnit.SECONDS);
return userInfo;
}
/**
* 更新用户信息
*/
public UserInfoResponse updateUser(Long userId, UpdateUserRequest request) {
log.info("更新用户信息: userId={}", userId);
User user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在"));
// 更新字段
boolean updated = false;
if (StringUtil.isNotEmpty(request.getNickname()) && !StringUtil.equals(user.getNickname(), request.getNickname())) {
user.setNickname(request.getNickname());
updated = true;
}
if (StringUtil.isNotEmpty(request.getAvatarUrl()) && !StringUtil.equals(user.getAvatarUrl(), request.getAvatarUrl())) {
user.setAvatarUrl(request.getAvatarUrl());
updated = true;
}
if (updated) {
user.setUpdatedAt(TimeUtil.getCurrentTimestamp());
userRepository.save(user);
// 更新缓存
cacheUserInfo(user);
log.info("用户信息更新成功: userId={}", userId);
}
return convertToUserInfoResponse(user);
}
/**
* 检查邮箱是否已存在
*/
@Transactional(readOnly = true)
public boolean existsByEmail(String email) {
return userRepository.existsByEmailAndDeletedAtIsNull(email.toLowerCase());
}
/**
* 检查用户状态
*/
private void checkUserStatus(User user) {
if (user.getStatus() == Constants.USER_STATUS_LOCKED) {
throw BusinessException.of(ErrorCode.FORBIDDEN_ERROR, "账户已被锁定,请联系管理员");
}
if (user.getStatus() == Constants.USER_STATUS_DELETED) {
throw BusinessException.of(ErrorCode.USER_NOT_FOUND, "用户不存在");
}
}
/**
* 检查登录失败次数
*/
private void checkLoginAttempts(String email) {
String key = Constants.REDIS_RATE_LIMIT_PREFIX + "login_fail:" + email;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
if (attempts != null && attempts >= 5) {
throw BusinessException.of(ErrorCode.RATE_LIMIT_ERROR, "登录失败次数过多,请稍后再试");
}
}
/**
* 记录登录失败
*/
private void recordLoginFailure(String email) {
String key = Constants.REDIS_RATE_LIMIT_PREFIX + "login_fail:" + email;
Integer attempts = (Integer) redisTemplate.opsForValue().get(key);
attempts = (attempts == null) ? 1 : attempts + 1;
redisTemplate.opsForValue().set(key, attempts, 15, TimeUnit.MINUTES);
log.warn("登录失败: email={}, 失败次数={}", email, attempts);
}
/**
* 清除登录失败记录
*/
private void clearLoginFailures(String email) {
String key = Constants.REDIS_RATE_LIMIT_PREFIX + "login_fail:" + email;
redisTemplate.delete(key);
}
/**
* 缓存用户信息到Redis
*/
private void cacheUserInfo(User user) {
String cacheKey = Constants.REDIS_USER_INFO_PREFIX + user.getId();
UserInfoResponse userInfo = convertToUserInfoResponse(user);
redisTemplate.opsForValue().set(cacheKey, userInfo, Constants.REDIS_USER_INFO_EXPIRE_TIME, TimeUnit.SECONDS);
}
/**
* 获取默认头像URL
*/
private String getDefaultAvatarUrl() {
// 可以配置默认头像列表,随机选择一个
return "https://via.placeholder.com/150x150?text=User";
}
/**
* 转换User实体为UserInfoResponse
*/
private UserInfoResponse convertToUserInfoResponse(User user) {
UserInfoResponse response = new UserInfoResponse();
response.setUserId(user.getId());
response.setEmail(user.getEmail());
response.setNickname(user.getNickname());
response.setAvatarUrl(user.getAvatarUrl());
response.setStatus(user.getStatus());
response.setLastLoginTime(user.getLastLoginTime());
response.setCreatedAt(user.getCreatedAt());
response.setUpdatedAt(user.getUpdatedAt());
return response;
}
}

View File

@ -0,0 +1,246 @@
package com.lxy.hsend.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 加密工具类
*
* @author lxy
*/
@Slf4j
public class CryptoUtil {
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private static final String AES_ALGORITHM = "AES";
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
/**
* 使用BCrypt加密密码
*/
public static String encryptPassword(String plainPassword) {
return passwordEncoder.encode(plainPassword);
}
/**
* 验证密码
*/
public static boolean verifyPassword(String plainPassword, String encryptedPassword) {
return passwordEncoder.matches(plainPassword, encryptedPassword);
}
/**
* MD5加密
*/
public static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] messageDigest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(messageDigest);
} catch (NoSuchAlgorithmException e) {
log.error("MD5加密失败", e);
return null;
}
}
/**
* SHA-256加密
*/
public static String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
log.error("SHA-256加密失败", e);
return null;
}
}
/**
* SHA-512加密
*/
public static String sha512(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
log.error("SHA-512加密失败", e);
return null;
}
}
/**
* 生成AES密钥
*/
public static String generateAESKey() {
try {
KeyGenerator keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM);
keyGenerator.init(256);
SecretKey secretKey = keyGenerator.generateKey();
return Base64.getEncoder().encodeToString(secretKey.getEncoded());
} catch (NoSuchAlgorithmException e) {
log.error("生成AES密钥失败", e);
return null;
}
}
/**
* AES加密
*/
public static String aesEncrypt(String plainText, String key) {
try {
SecretKeySpec secretKey = new SecretKeySpec(Base64.getDecoder().decode(key), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.error("AES加密失败", e);
return null;
}
}
/**
* AES解密
*/
public static String aesDecrypt(String encryptedText, String key) {
try {
SecretKeySpec secretKey = new SecretKeySpec(Base64.getDecoder().decode(key), AES_ALGORITHM);
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("AES解密失败", e);
return null;
}
}
/**
* Base64编码
*/
public static String base64Encode(String input) {
return Base64.getEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_8));
}
/**
* Base64解码
*/
public static String base64Decode(String encodedInput) {
try {
byte[] decodedBytes = Base64.getDecoder().decode(encodedInput);
return new String(decodedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Base64解码失败", e);
return null;
}
}
/**
* 生成随机盐值
*/
public static String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return bytesToHex(salt);
}
/**
* 带盐值的哈希
*/
public static String hashWithSalt(String input, String salt) {
return sha256(input + salt);
}
/**
* 验证带盐值的哈希
*/
public static boolean verifyHashWithSalt(String input, String salt, String hash) {
String computedHash = hashWithSalt(input, salt);
return StringUtil.equals(computedHash, hash);
}
/**
* 字节数组转十六进制字符串
*/
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
/**
* 十六进制字符串转字节数组
*/
public static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
/**
* 生成安全的随机数
*/
public static String generateSecureRandomString(int length) {
SecureRandom random = new SecureRandom();
StringBuilder sb = new StringBuilder();
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}
/**
* 简单的异或加密/解密
*/
public static String xorEncrypt(String input, String key) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < input.length(); i++) {
result.append((char) (input.charAt(i) ^ key.charAt(i % key.length())));
}
return Base64.getEncoder().encodeToString(result.toString().getBytes(StandardCharsets.UTF_8));
}
/**
* 异或解密
*/
public static String xorDecrypt(String encryptedInput, String key) {
try {
String decoded = new String(Base64.getDecoder().decode(encryptedInput), StandardCharsets.UTF_8);
StringBuilder result = new StringBuilder();
for (int i = 0; i < decoded.length(); i++) {
result.append((char) (decoded.charAt(i) ^ key.charAt(i % key.length())));
}
return result.toString();
} catch (Exception e) {
log.error("异或解密失败", e);
return null;
}
}
private CryptoUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,237 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* JWT工具类
*
* @author lxy
*/
@Slf4j
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public JwtUtil(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.secretKey = Keys.hmacShaKeyFor(Constants.JWT_SECRET_KEY.getBytes());
}
/**
* 生成JWT Token
*/
public String generateToken(Long userId, String email) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("email", email);
claims.put("type", "access");
return createToken(claims, userId.toString(), Constants.JWT_EXPIRATION_TIME);
}
/**
* 生成刷新Token
*/
public String generateRefreshToken(Long userId, String email) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("email", email);
claims.put("type", "refresh");
return createToken(claims, userId.toString(), Constants.JWT_REFRESH_TIME);
}
/**
* 创建Token
*/
private String createToken(Map<String, Object> claims, String subject, long expiration) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
/**
* 验证Token
*/
public boolean validateToken(String token) {
try {
// 检查是否在黑名单中
if (isTokenBlacklisted(token)) {
log.warn("Token已被加入黑名单: {}", token);
return false;
}
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException e) {
log.error("JWT签名无效: {}", e.getMessage());
} catch (MalformedJwtException e) {
log.error("JWT格式错误: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.warn("JWT已过期: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("不支持的JWT: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT为空: {}", e.getMessage());
}
return false;
}
/**
* 从Token中获取用户ID
*/
public Long getUserIdFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.get("userId", Long.class) : null;
}
/**
* 从Token中获取邮箱
*/
public String getEmailFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.get("email", String.class) : null;
}
/**
* 从Token中获取Token类型
*/
public String getTokenTypeFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.get("type", String.class) : null;
}
/**
* 获取Token过期时间
*/
public Date getExpirationDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.getExpiration() : null;
}
/**
* 检查Token是否过期
*/
public boolean isTokenExpired(String token) {
Date expiration = getExpirationDateFromToken(token);
return expiration != null && expiration.before(new Date());
}
/**
* 从Token中获取Claims
*/
private Claims getClaimsFromToken(String token) {
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (Exception e) {
log.error("解析Token失败: {}", e.getMessage());
return null;
}
}
/**
* 将Token加入黑名单
*/
public void blacklistToken(String token) {
try {
Date expiration = getExpirationDateFromToken(token);
if (expiration != null) {
String key = Constants.REDIS_JWT_BLACKLIST_PREFIX + token;
long ttl = expiration.getTime() - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue().set(key, "blacklisted", ttl, TimeUnit.MILLISECONDS);
log.info("Token已加入黑名单: {}", token);
}
}
} catch (Exception e) {
log.error("将Token加入黑名单失败: {}", e.getMessage());
}
}
/**
* 检查Token是否在黑名单中
*/
public boolean isTokenBlacklisted(String token) {
try {
String key = Constants.REDIS_JWT_BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
log.error("检查Token黑名单状态失败: {}", e.getMessage());
return false;
}
}
/**
* 刷新Token
*/
public String refreshToken(String refreshToken) {
if (!validateToken(refreshToken)) {
throw new IllegalArgumentException("刷新Token无效");
}
String tokenType = getTokenTypeFromToken(refreshToken);
if (!"refresh".equals(tokenType)) {
throw new IllegalArgumentException("Token类型错误");
}
Long userId = getUserIdFromToken(refreshToken);
String email = getEmailFromToken(refreshToken);
if (userId == null || email == null) {
throw new IllegalArgumentException("Token信息不完整");
}
return generateToken(userId, email);
}
/**
* 从请求头中提取Token
*/
public String extractTokenFromHeader(String authHeader) {
if (authHeader != null && authHeader.startsWith(Constants.JWT_TOKEN_PREFIX)) {
return authHeader.substring(Constants.JWT_TOKEN_PREFIX.length());
}
return null;
}
/**
* 获取Token剩余有效时间
*/
public long getTokenRemainingTime(String token) {
Date expiration = getExpirationDateFromToken(token);
if (expiration != null) {
long remaining = expiration.getTime() - System.currentTimeMillis();
return Math.max(0, remaining / 1000);
}
return 0;
}
}

View File

@ -0,0 +1,243 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import java.util.List;
/**
* 分页工具类
*
* @author lxy
*/
public class PageUtil {
/**
* 创建分页对象
*/
public static Pageable createPageable(Integer page, Integer size) {
return createPageable(page, size, null);
}
/**
* 创建带排序的分页对象
*/
public static Pageable createPageable(Integer page, Integer size, Sort sort) {
page = validatePage(page);
size = validateSize(size);
if (sort != null) {
return PageRequest.of(page - 1, size, sort);
} else {
return PageRequest.of(page - 1, size);
}
}
/**
* 创建降序排序的分页对象
*/
public static Pageable createPageableDesc(Integer page, Integer size, String... properties) {
Sort sort = Sort.by(Sort.Direction.DESC, properties);
return createPageable(page, size, sort);
}
/**
* 创建升序排序的分页对象
*/
public static Pageable createPageableAsc(Integer page, Integer size, String... properties) {
Sort sort = Sort.by(Sort.Direction.ASC, properties);
return createPageable(page, size, sort);
}
/**
* 验证页码
*/
private static Integer validatePage(Integer page) {
if (page == null || page < 1) {
return Constants.DEFAULT_PAGE_NUM;
}
return page;
}
/**
* 验证页面大小
*/
private static Integer validateSize(Integer size) {
if (size == null || size < 1) {
return Constants.DEFAULT_PAGE_SIZE;
}
if (size > Constants.MAX_PAGE_SIZE) {
return Constants.MAX_PAGE_SIZE;
}
return size;
}
/**
* 将Spring Data的Page对象转换为自定义的分页响应对象
*/
public static <T> PageResponse<T> toPageResponse(Page<T> page) {
return new PageResponse<T>(
page.getContent(),
page.getNumber() + 1, // Spring Data的页码从0开始转换为从1开始
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
);
}
/**
* 创建空的分页响应
*/
public static <T> PageResponse<T> emptyPageResponse(Integer page, Integer size) {
page = validatePage(page);
size = validateSize(size);
return new PageResponse<T>(
List.of(),
page,
size,
0L,
0,
true,
true,
false,
false
);
}
/**
* 分页响应对象
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class PageResponse<T> {
/**
* 数据列表
*/
private List<T> content;
/**
* 当前页码从1开始
*/
private Integer page;
/**
* 页面大小
*/
private Integer size;
/**
* 总记录数
*/
private Long total;
/**
* 总页数
*/
private Integer totalPages;
/**
* 是否是第一页
*/
private Boolean first;
/**
* 是否是最后一页
*/
private Boolean last;
/**
* 是否有下一页
*/
private Boolean hasNext;
/**
* 是否有上一页
*/
private Boolean hasPrevious;
}
/**
* 分页查询参数
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class PageQuery {
/**
* 页码从1开始
*/
private Integer page = Constants.DEFAULT_PAGE_NUM;
/**
* 页面大小
*/
private Integer size = Constants.DEFAULT_PAGE_SIZE;
/**
* 排序字段
*/
private String sortBy;
/**
* 排序方向asc/desc
*/
private String sortDir = "desc";
/**
* 获取Pageable对象
*/
public Pageable toPageable() {
if (StringUtil.isNotEmpty(sortBy)) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ?
Sort.Direction.ASC : Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return PageUtil.createPageable(page, size, sort);
} else {
return PageUtil.createPageable(page, size);
}
}
}
/**
* 计算偏移量
*/
public static long calculateOffset(Integer page, Integer size) {
page = validatePage(page);
size = validateSize(size);
return (long) (page - 1) * size;
}
/**
* 计算总页数
*/
public static int calculateTotalPages(long totalElements, int size) {
return (int) Math.ceil((double) totalElements / size);
}
/**
* 检查页码是否有效
*/
public static boolean isValidPage(Integer page, long totalElements, Integer size) {
if (page == null || page < 1) {
return false;
}
size = validateSize(size);
int totalPages = calculateTotalPages(totalElements, size);
return page <= totalPages;
}
private PageUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,276 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 字符串工具类
*
* @author lxy
*/
public class StringUtil {
private static final Pattern EMAIL_PATTERN = Pattern.compile(Constants.EMAIL_REGEX);
private static final Pattern PASSWORD_PATTERN = Pattern.compile(Constants.PASSWORD_REGEX);
/**
* 检查字符串是否为空或null
*/
public static boolean isEmpty(String str) {
return str == null || str.trim().isEmpty();
}
/**
* 检查字符串是否不为空
*/
public static boolean isNotEmpty(String str) {
return !isEmpty(str);
}
/**
* 安全的字符串比较(避免空指针异常)
*/
public static boolean equals(String str1, String str2) {
if (str1 == null && str2 == null) {
return true;
}
if (str1 == null || str2 == null) {
return false;
}
return str1.equals(str2);
}
/**
* 安全的字符串比较(忽略大小写)
*/
public static boolean equalsIgnoreCase(String str1, String str2) {
if (str1 == null && str2 == null) {
return true;
}
if (str1 == null || str2 == null) {
return false;
}
return str1.equalsIgnoreCase(str2);
}
/**
* 生成UUID字符串去掉连字符
*/
public static String generateUuid() {
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 生成标准UUID字符串带连字符
*/
public static String generateStandardUuid() {
return UUID.randomUUID().toString();
}
/**
* 验证邮箱格式
*/
public static boolean isValidEmail(String email) {
return isNotEmpty(email) && EMAIL_PATTERN.matcher(email).matches();
}
/**
* 验证密码格式
*/
public static boolean isValidPassword(String password) {
return isNotEmpty(password) && PASSWORD_PATTERN.matcher(password).matches();
}
/**
* 验证用户名长度
*/
public static boolean isValidNickname(String nickname) {
return isNotEmpty(nickname) &&
nickname.length() >= Constants.NICKNAME_MIN_LENGTH &&
nickname.length() <= Constants.NICKNAME_MAX_LENGTH;
}
/**
* 掩码邮箱地址(隐藏部分字符)
*/
public static String maskEmail(String email) {
if (isEmpty(email) || !email.contains("@")) {
return email;
}
String[] parts = email.split("@");
String username = parts[0];
String domain = parts[1];
if (username.length() <= 2) {
return email;
}
String maskedUsername = username.charAt(0) +
"*".repeat(username.length() - 2) +
username.charAt(username.length() - 1);
return maskedUsername + "@" + domain;
}
/**
* 掩码手机号码
*/
public static String maskPhoneNumber(String phoneNumber) {
if (isEmpty(phoneNumber) || phoneNumber.length() < 7) {
return phoneNumber;
}
return phoneNumber.substring(0, 3) +
"*".repeat(phoneNumber.length() - 6) +
phoneNumber.substring(phoneNumber.length() - 3);
}
/**
* 截断字符串到指定长度
*/
public static String truncate(String str, int maxLength) {
if (isEmpty(str) || str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength) + "...";
}
/**
* 移除字符串中的HTML标签
*/
public static String removeHtmlTags(String html) {
if (isEmpty(html)) {
return html;
}
return html.replaceAll("<[^>]+>", "");
}
/**
* 转义HTML特殊字符
*/
public static String escapeHtml(String str) {
if (isEmpty(str)) {
return str;
}
return str.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;");
}
/**
* 将字符串转换为驼峰命名
*/
public static String toCamelCase(String str) {
if (isEmpty(str)) {
return str;
}
StringBuilder result = new StringBuilder();
boolean capitalizeNext = false;
for (char c : str.toCharArray()) {
if (c == '_' || c == '-' || c == ' ') {
capitalizeNext = true;
} else if (capitalizeNext) {
result.append(Character.toUpperCase(c));
capitalizeNext = false;
} else {
result.append(Character.toLowerCase(c));
}
}
return result.toString();
}
/**
* 将驼峰命名转换为下划线命名
*/
public static String toSnakeCase(String str) {
if (isEmpty(str)) {
return str;
}
StringBuilder result = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (Character.isUpperCase(c)) {
if (i > 0) {
result.append('_');
}
result.append(Character.toLowerCase(c));
} else {
result.append(c);
}
}
return result.toString();
}
/**
* 生成随机字符串
*/
public static String generateRandomString(int length) {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder result = new StringBuilder();
for (int i = 0; i < length; i++) {
result.append(chars.charAt((int) (Math.random() * chars.length())));
}
return result.toString();
}
/**
* 生成随机数字字符串
*/
public static String generateRandomNumbers(int length) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < length; i++) {
result.append((int) (Math.random() * 10));
}
return result.toString();
}
/**
* 统计字符串中的字数中文按1个字计算英文按0.5个字计算)
*/
public static double countWords(String str) {
if (isEmpty(str)) {
return 0;
}
double count = 0;
for (char c : str.toCharArray()) {
if (c >= 0x4e00 && c <= 0x9fa5) {
// 中文字符
count += 1;
} else if (Character.isLetter(c)) {
// 英文字符
count += 0.5;
}
}
return count;
}
/**
* 获取字符串的字节长度UTF-8编码
*/
public static int getByteLength(String str) {
if (isEmpty(str)) {
return 0;
}
return str.getBytes().length;
}
private StringUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,220 @@
package com.lxy.hsend.util;
import com.lxy.hsend.common.Constants;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 时间工具类
*
* @author lxy
*/
public class TimeUtil {
private static final ZoneId ZONE_ID = ZoneId.of(Constants.TIMEZONE);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(Constants.DATE_FORMAT);
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern(Constants.DATETIME_FORMAT);
/**
* 获取当前时间戳(毫秒)
*/
public static long getCurrentTimestamp() {
return System.currentTimeMillis();
}
/**
* 获取当前时间戳(秒)
*/
public static long getCurrentTimestampSeconds() {
return System.currentTimeMillis() / 1000;
}
/**
* 获取当前日期时间
*/
public static LocalDateTime getCurrentDateTime() {
return LocalDateTime.now(ZONE_ID);
}
/**
* 获取当前日期
*/
public static LocalDate getCurrentDate() {
return LocalDate.now(ZONE_ID);
}
/**
* 时间戳转LocalDateTime
*/
public static LocalDateTime timestampToLocalDateTime(long timestamp) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZONE_ID);
}
/**
* LocalDateTime转时间戳
*/
public static long localDateTimeToTimestamp(LocalDateTime dateTime) {
return dateTime.atZone(ZONE_ID).toInstant().toEpochMilli();
}
/**
* 时间戳转Date
*/
public static Date timestampToDate(long timestamp) {
return new Date(timestamp);
}
/**
* Date转时间戳
*/
public static long dateToTimestamp(Date date) {
return date.getTime();
}
/**
* 格式化日期时间
*/
public static String formatDateTime(LocalDateTime dateTime) {
return dateTime.format(DATETIME_FORMATTER);
}
/**
* 格式化日期
*/
public static String formatDate(LocalDate date) {
return date.format(DATE_FORMATTER);
}
/**
* 格式化时间戳为日期时间字符串
*/
public static String formatTimestamp(long timestamp) {
return formatDateTime(timestampToLocalDateTime(timestamp));
}
/**
* 解析日期时间字符串
*/
public static LocalDateTime parseDateTime(String dateTimeStr) {
return LocalDateTime.parse(dateTimeStr, DATETIME_FORMATTER);
}
/**
* 解析日期字符串
*/
public static LocalDate parseDate(String dateStr) {
return LocalDate.parse(dateStr, DATE_FORMATTER);
}
/**
* 计算两个时间戳之间的天数差
*/
public static long daysBetween(long timestamp1, long timestamp2) {
LocalDate date1 = timestampToLocalDateTime(timestamp1).toLocalDate();
LocalDate date2 = timestampToLocalDateTime(timestamp2).toLocalDate();
return Duration.between(date1.atStartOfDay(), date2.atStartOfDay()).toDays();
}
/**
* 计算两个时间戳之间的小时差
*/
public static long hoursBetween(long timestamp1, long timestamp2) {
return (timestamp2 - timestamp1) / (1000 * 60 * 60);
}
/**
* 计算两个时间戳之间的分钟差
*/
public static long minutesBetween(long timestamp1, long timestamp2) {
return (timestamp2 - timestamp1) / (1000 * 60);
}
/**
* 计算两个时间戳之间的秒数差
*/
public static long secondsBetween(long timestamp1, long timestamp2) {
return (timestamp2 - timestamp1) / 1000;
}
/**
* 获取今天开始时间戳
*/
public static long getTodayStartTimestamp() {
LocalDateTime startOfDay = getCurrentDate().atStartOfDay();
return localDateTimeToTimestamp(startOfDay);
}
/**
* 获取今天结束时间戳
*/
public static long getTodayEndTimestamp() {
LocalDateTime endOfDay = getCurrentDate().atTime(23, 59, 59, 999_000_000);
return localDateTimeToTimestamp(endOfDay);
}
/**
* 获取指定天数前的时间戳
*/
public static long getDaysAgoTimestamp(int days) {
LocalDateTime daysAgo = getCurrentDateTime().minusDays(days);
return localDateTimeToTimestamp(daysAgo);
}
/**
* 获取指定小时前的时间戳
*/
public static long getHoursAgoTimestamp(int hours) {
LocalDateTime hoursAgo = getCurrentDateTime().minusHours(hours);
return localDateTimeToTimestamp(hoursAgo);
}
/**
* 获取指定分钟前的时间戳
*/
public static long getMinutesAgoTimestamp(int minutes) {
LocalDateTime minutesAgo = getCurrentDateTime().minusMinutes(minutes);
return localDateTimeToTimestamp(minutesAgo);
}
/**
* 检查时间戳是否是今天
*/
public static boolean isToday(long timestamp) {
LocalDate date = timestampToLocalDateTime(timestamp).toLocalDate();
return date.equals(getCurrentDate());
}
/**
* 检查时间戳是否是昨天
*/
public static boolean isYesterday(long timestamp) {
LocalDate date = timestampToLocalDateTime(timestamp).toLocalDate();
return date.equals(getCurrentDate().minusDays(1));
}
/**
* 获取友好的时间显示刚刚、5分钟前、1小时前等
*/
public static String getFriendlyTime(long timestamp) {
long now = getCurrentTimestamp();
long diff = now - timestamp;
if (diff < 60 * 1000) {
return "刚刚";
} else if (diff < 60 * 60 * 1000) {
return (diff / (60 * 1000)) + "分钟前";
} else if (diff < 24 * 60 * 60 * 1000) {
return (diff / (60 * 60 * 1000)) + "小时前";
} else if (diff < 30L * 24 * 60 * 60 * 1000) {
return (diff / (24 * 60 * 60 * 1000)) + "天前";
} else {
return formatTimestamp(timestamp);
}
}
private TimeUtil() {
// 工具类不允许实例化
}
}

View File

@ -0,0 +1,61 @@
# 开发环境配置
spring:
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/hsai?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
# JPA配置
jpa:
hibernate:
ddl-auto: validate
show-sql: true
# Flyway配置
flyway:
enabled: true
properties:
hibernate:
format_sql: true
# Redis配置
data:
redis:
host: localhost
port: 6379
database: 0
# 服务器配置
server:
port: 8080
# 日志配置
logging:
level:
com.lxy.hsend: debug
org.springframework.security: debug
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql.BasicBinder: trace
# JWT配置开发环境使用简单密钥
jwt:
secret: hsendDevSecretKey2024
expiration: 86400 # 1天
# AI服务配置开发环境
ai:
service:
timeout: 30000
retry-count: 3
base-url: https://api-dev.example.com
api-key: dev-api-key
# 文件存储配置
file:
upload:
path: ./uploads/dev
# 跨域配置(开发环境允许所有来源)
cors:
allowed-origins: "*"

View File

@ -0,0 +1,62 @@
# 生产环境配置
spring:
# 数据库配置(生产环境从环境变量读取)
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 300000
max-lifetime: 900000
# JPA配置
jpa:
hibernate:
ddl-auto: validate
show-sql: false
# Redis配置
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
database: 0
# 服务器配置
server:
port: ${SERVER_PORT:8080}
# 日志配置
logging:
level:
com.lxy.hsend: info
org.springframework.security: warn
org.hibernate.SQL: warn
file:
name: /var/log/hs-end/hs-end.log
# JWT配置生产环境必须使用环境变量
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:604800}
# AI服务配置
ai:
service:
timeout: ${AI_TIMEOUT:30000}
retry-count: ${AI_RETRY:3}
base-url: ${AI_SERVICE_URL}
api-key: ${AI_API_KEY}
# 文件存储配置
file:
upload:
path: ${FILE_UPLOAD_PATH:/data/uploads}
# 跨域配置(生产环境严格控制)
cors:
allowed-origins: ${CORS_ORIGINS}

View File

@ -0,0 +1,62 @@
# 测试环境配置
spring:
# 数据库配置 - 使用H2内存数据库进行测试
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
driver-class-name: org.h2.Driver
# JPA配置
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
# H2控制台配置
h2:
console:
enabled: true
# Flyway配置 - 测试环境禁用
flyway:
enabled: false
# Redis配置
data:
redis:
host: localhost
port: 6379
database: 1
# 服务器配置
server:
port: 8081
# 日志配置
logging:
level:
com.lxy.hsend: info
org.springframework.security: info
# JWT配置
jwt:
secret: hsendTestSecretKey2024
expiration: 3600 # 1小时
# AI服务配置测试环境使用Mock
ai:
service:
timeout: 10000
retry-count: 1
base-url: http://localhost:8082/mock
api-key: test-api-key
# 文件存储配置
file:
upload:
path: ./uploads/test

View File

@ -0,0 +1,134 @@
spring:
application:
name: hs-end
profiles:
active: dev
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${DB_URL:jdbc:mysql://localhost:3306/hsai?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:root}
# 连接池配置
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
# JPA配置
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
format_sql: true
# Flyway数据库迁移配置
flyway:
enabled: true
baseline-on-migrate: true
validate-on-migrate: true
locations: classpath:db/migration
# Redis配置
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 20
max-wait: -1ms
max-idle: 10
min-idle: 0
# 文件上传配置
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
# Jackson配置
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
default-property-inclusion: non_null
# 服务器配置
server:
port: 8080
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
# Actuator健康检查配置
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
# 日志配置
logging:
level:
com.lxy.hsend: info
org.springframework.security: debug
org.hibernate.SQL: debug
org.hibernate.type.descriptor.sql.BasicBinder: trace
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/hs-end.log
max-size: 10MB
max-history: 30
# JWT配置
jwt:
secret: ${JWT_SECRET:hsendSecretKeyForJWTTokenGenerationAndValidation2024}
expiration: 604800 # 7天
# AI服务配置
ai:
service:
timeout: 30000
retry-count: 3
base-url: ${AI_SERVICE_URL:https://api.example.com}
api-key: ${AI_API_KEY:your-api-key}
# 文件存储配置
file:
upload:
path: ${FILE_UPLOAD_PATH:./uploads}
allowed-types: .txt,.pdf,.md,.docx,.doc,.xlsx,.xls,.png,.jpg,.jpeg
max-size: 10485760 # 10MB
# Swagger配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
# 跨域配置
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:3000,http://localhost:8080}
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: "*"
allow-credentials: true

View File

@ -0,0 +1,166 @@
-- 创建数据库初始表结构
-- 版本: V1
-- 描述: 创建用户、会话、消息、收藏、文件等核心业务表
-- 设置字符集
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 用户表
-- ----------------------------
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`email` VARCHAR(100) NOT NULL COMMENT '用户邮箱',
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希',
`nickname` VARCHAR(50) NOT NULL COMMENT '用户昵称',
`avatar_url` VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT DEFAULT 1 COMMENT '用户状态1-正常2-锁定3-删除',
`last_login_time` BIGINT DEFAULT NULL COMMENT '最后登录时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- ----------------------------
-- 会话表
-- ----------------------------
CREATE TABLE `sessions` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话自增ID',
`session_id` VARCHAR(36) NOT NULL COMMENT '会话UUID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`title` VARCHAR(200) NOT NULL COMMENT '会话标题',
`message_count` INT DEFAULT 0 COMMENT '消息数量',
`last_message_time` BIGINT DEFAULT NULL COMMENT '最后消息时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_id` (`session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_last_message_time` (`last_message_time`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_sessions_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='聊天会话表';
-- ----------------------------
-- 消息表
-- ----------------------------
CREATE TABLE `messages` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '消息自增ID',
`message_id` VARCHAR(36) NOT NULL COMMENT '消息UUID',
`session_id` VARCHAR(36) NOT NULL COMMENT '会话ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role` VARCHAR(20) NOT NULL COMMENT '角色user/assistant',
`content` LONGTEXT NOT NULL COMMENT '消息内容',
`model_used` VARCHAR(50) DEFAULT NULL COMMENT '使用的AI模型',
`deep_thinking` BOOLEAN DEFAULT FALSE COMMENT '是否启用深度思考',
`web_search` BOOLEAN DEFAULT FALSE COMMENT '是否启用联网搜索',
`is_favorited` BOOLEAN DEFAULT FALSE COMMENT '是否已收藏',
`timestamp` BIGINT NOT NULL COMMENT '消息时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_message_id` (`message_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_role` (`role`),
KEY `idx_timestamp` (`timestamp`),
KEY `idx_is_favorited` (`is_favorited`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_messages_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
-- ----------------------------
-- 收藏表
-- ----------------------------
CREATE TABLE `favorites` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '收藏自增ID',
`favorite_id` VARCHAR(36) NOT NULL COMMENT '收藏UUID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`message_id` VARCHAR(36) NOT NULL COMMENT '消息ID',
`session_id` VARCHAR(36) NOT NULL COMMENT '会话ID',
`created_at` BIGINT NOT NULL COMMENT '收藏时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_favorite_id` (`favorite_id`),
UNIQUE KEY `uk_user_message` (`user_id`, `message_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_message_id` (`message_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_favorites_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收藏表';
-- ----------------------------
-- 文件表
-- ----------------------------
CREATE TABLE `files` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '文件自增ID',
`file_id` VARCHAR(36) NOT NULL COMMENT '文件UUID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`session_id` VARCHAR(36) DEFAULT NULL COMMENT '关联会话ID',
`filename` VARCHAR(255) NOT NULL COMMENT '原始文件名',
`stored_filename` VARCHAR(255) NOT NULL COMMENT '存储文件名',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件存储路径',
`file_size` BIGINT NOT NULL COMMENT '文件大小(字节)',
`file_type` VARCHAR(100) NOT NULL COMMENT '文件类型',
`mime_type` VARCHAR(100) DEFAULT NULL COMMENT 'MIME类型',
`upload_time` BIGINT NOT NULL COMMENT '上传时间戳',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
`deleted_at` BIGINT DEFAULT NULL COMMENT '删除时间戳(软删除)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_file_id` (`file_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_session_id` (`session_id`),
KEY `idx_file_type` (`file_type`),
KEY `idx_upload_time` (`upload_time`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_files_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';
-- ----------------------------
-- 操作日志表
-- ----------------------------
CREATE TABLE `operation_logs` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID',
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型',
`operation_desc` VARCHAR(500) DEFAULT NULL COMMENT '操作描述',
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理',
`request_params` TEXT DEFAULT NULL COMMENT '请求参数',
`response_result` TEXT DEFAULT NULL COMMENT '响应结果',
`execution_time` INT DEFAULT NULL COMMENT '执行时长(毫秒)',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_operation_type` (`operation_type`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表';
-- ----------------------------
-- 系统配置表
-- ----------------------------
CREATE TABLE `system_configs` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '配置ID',
`config_key` VARCHAR(100) NOT NULL COMMENT '配置键',
`config_value` TEXT DEFAULT NULL COMMENT '配置值',
`config_desc` VARCHAR(500) DEFAULT NULL COMMENT '配置描述',
`config_type` VARCHAR(20) DEFAULT 'STRING' COMMENT '配置类型STRING/NUMBER/BOOLEAN/JSON',
`is_encrypted` BOOLEAN DEFAULT FALSE COMMENT '是否加密存储',
`created_at` BIGINT NOT NULL COMMENT '创建时间戳',
`updated_at` BIGINT NOT NULL COMMENT '更新时间戳',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_config_key` (`config_key`),
KEY `idx_config_type` (`config_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -0,0 +1,13 @@
package com.lxy.hsend;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class HsEndApplicationTests {
@Test
void contextLoads() {
}
}

View File

@ -0,0 +1,105 @@
package com.lxy.hsend.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lxy.hsend.dto.auth.LoginRequest;
import com.lxy.hsend.dto.auth.RegisterRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 认证控制器集成测试
*
* @author lxy
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Transactional
public class AuthControllerTest {
@Autowired
private WebApplicationContext webApplicationContext;
@Autowired
private ObjectMapper objectMapper;
private MockMvc mockMvc;
@Test
public void testUserRegistrationFlow() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
// 1. 测试用户注册
RegisterRequest registerRequest = new RegisterRequest();
registerRequest.setEmail("test@example.com");
registerRequest.setPassword("Test123456");
registerRequest.setConfirmPassword("Test123456");
registerRequest.setNickname("测试用户");
String registerJson = objectMapper.writeValueAsString(registerRequest);
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.body.email").value("test@example.com"))
.andExpect(jsonPath("$.body.nickname").value("测试用户"))
.andExpect(jsonPath("$.message").value("注册成功"));
System.out.println("✅ 用户注册测试通过");
// 2. 测试用户登录
LoginRequest loginRequest = new LoginRequest();
loginRequest.setEmail("test@example.com");
loginRequest.setPassword("Test123456");
String loginJson = objectMapper.writeValueAsString(loginRequest);
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.body.accessToken").exists())
.andExpect(jsonPath("$.body.refreshToken").exists())
.andExpect(jsonPath("$.body.userInfo.email").value("test@example.com"))
.andExpect(jsonPath("$.message").value("登录成功"));
System.out.println("✅ 用户登录测试通过");
// 3. 测试邮箱重复注册
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(registerJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error").value(2003));
System.out.println("✅ 邮箱重复注册验证测试通过");
}
@Test
public void testEmailCheck() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
// 测试邮箱是否已存在检查
mockMvc.perform(get("/api/auth/check-email")
.param("email", "nonexistent@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.body").value(false))
.andExpect(jsonPath("$.message").value("邮箱可用"));
System.out.println("✅ 邮箱检查测试通过");
}
}

View File

@ -0,0 +1,203 @@
package com.lxy.hsend.repository;
import com.lxy.hsend.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 数据库连接和表创建测试
*/
@DataJpaTest
@ActiveProfiles("test")
public class DatabaseConnectionTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private DataSource dataSource;
@Autowired
private UserRepository userRepository;
@Test
public void testDatabaseConnection() throws Exception {
// 测试数据库连接
try (Connection connection = dataSource.getConnection()) {
assertThat(connection).isNotNull();
assertThat(connection.isValid(1)).isTrue();
DatabaseMetaData metaData = connection.getMetaData();
System.out.println("Database Product Name: " + metaData.getDatabaseProductName());
System.out.println("Database Product Version: " + metaData.getDatabaseProductVersion());
}
}
@Test
public void testTablesExist() throws Exception {
// 测试核心表是否存在
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metaData = connection.getMetaData();
// 检查用户表
try (ResultSet rs = metaData.getTables(null, null, "users", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ users table exists");
}
// 检查会话表
try (ResultSet rs = metaData.getTables(null, null, "sessions", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ sessions table exists");
}
// 检查消息表
try (ResultSet rs = metaData.getTables(null, null, "messages", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ messages table exists");
}
// 检查收藏表
try (ResultSet rs = metaData.getTables(null, null, "favorites", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ favorites table exists");
}
// 检查文件表
try (ResultSet rs = metaData.getTables(null, null, "files", null)) {
assertThat(rs.next()).isTrue();
System.out.println("✓ files table exists");
}
}
}
@Test
public void testUserRepositoryBasicOperations() {
// 测试基本的CRUD操作
// 创建测试用户
User user = new User();
user.setEmail("test@example.com");
user.setPasswordHash("hashedPassword");
user.setNickname("TestUser");
user.setStatus((byte) 1);
// 保存用户
User savedUser = userRepository.save(user);
assertThat(savedUser.getId()).isNotNull();
assertThat(savedUser.getEmail()).isEqualTo("test@example.com");
System.out.println("✓ User saved successfully: " + savedUser);
// 通过邮箱查找用户
var foundUser = userRepository.findByEmail("test@example.com");
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getNickname()).isEqualTo("TestUser");
System.out.println("✓ User found by email: " + foundUser.get());
// 检查邮箱是否存在
boolean exists = userRepository.existsByEmail("test@example.com");
assertThat(exists).isTrue();
System.out.println("✓ Email exists check passed");
// 统计用户数量
long count = userRepository.count();
assertThat(count).isGreaterThan(0);
System.out.println("✓ User count: " + count);
// 删除用户
userRepository.delete(savedUser);
// 验证删除
var deletedUser = userRepository.findById(savedUser.getId());
assertThat(deletedUser).isEmpty();
System.out.println("✓ User deleted successfully");
}
@Test
public void testUserValidation() {
// 测试用户实体验证
User user = new User();
user.setEmail("invalid-email"); // 无效邮箱格式
user.setPasswordHash("password");
user.setNickname("Test");
try {
entityManager.persistAndFlush(user);
// 如果没有抛出异常,说明验证可能有问题
} catch (Exception e) {
// 预期会抛出验证异常
System.out.println("✓ Email validation working: " + e.getMessage());
}
}
@Test
public void testTimestampFields() {
// 测试时间戳字段
User user = new User();
user.setEmail("timestamp@test.com");
user.setPasswordHash("password");
user.setNickname("TimestampTest");
long beforeSave = Instant.now().getEpochSecond();
User savedUser = userRepository.save(user);
long afterSave = Instant.now().getEpochSecond();
// 验证创建时间和更新时间
assertThat(savedUser.getCreatedAt()).isNotNull();
assertThat(savedUser.getUpdatedAt()).isNotNull();
assertThat(savedUser.getCreatedAt()).isBetween(beforeSave - 1, afterSave + 1);
assertThat(savedUser.getUpdatedAt()).isBetween(beforeSave - 1, afterSave + 1);
System.out.println("✓ Timestamp fields working correctly");
System.out.println(" Created at: " + savedUser.getCreatedAt());
System.out.println(" Updated at: " + savedUser.getUpdatedAt());
// 清理
userRepository.delete(savedUser);
}
@Test
public void testSoftDelete() {
// 测试软删除功能
User user = new User();
user.setEmail("softdelete@test.com");
user.setPasswordHash("password");
user.setNickname("SoftDeleteTest");
User savedUser = userRepository.save(user);
Long userId = savedUser.getId();
// 执行软删除
savedUser.markAsDeleted();
userRepository.save(savedUser);
// 验证软删除
User deletedUser = userRepository.findById(userId).orElse(null);
assertThat(deletedUser).isNotNull();
assertThat(deletedUser.isDeleted()).isTrue();
assertThat(deletedUser.getDeletedAt()).isNotNull();
System.out.println("✓ Soft delete working correctly");
System.out.println(" Deleted at: " + deletedUser.getDeletedAt());
// 测试查找未删除的用户
var activeUser = userRepository.findByEmailAndNotDeleted("softdelete@test.com");
assertThat(activeUser).isEmpty();
System.out.println("✓ Soft delete query filter working");
// 清理
userRepository.delete(deletedUser);
}
}

View File

@ -0,0 +1,250 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.favorite.AddFavoriteRequest;
import com.lxy.hsend.dto.favorite.FavoriteListRequest;
import com.lxy.hsend.dto.favorite.FavoriteResponse;
import com.lxy.hsend.dto.favorite.RemoveFavoriteRequest;
import com.lxy.hsend.entity.Favorite;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.repository.FavoriteRepository;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 收藏服务测试
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class FavoriteServiceTest {
@Autowired
private FavoriteService favoriteService;
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
@Autowired
private MessageRepository messageRepository;
@Autowired
private FavoriteRepository favoriteRepository;
private User testUser;
private Session testSession;
private Message testMessage;
@BeforeEach
void setUp() {
// 创建测试用户
testUser = new User();
testUser.setEmail("test@example.com");
testUser.setNickname("testuser");
testUser.setPasswordHash("hashedpassword");
testUser.setStatus((byte) 1);
testUser = userRepository.save(testUser);
// 创建测试会话
testSession = new Session();
testSession.setSessionId(UUID.randomUUID().toString());
testSession.setUserId(testUser.getId());
testSession.setTitle("测试会话");
testSession.setMessageCount(0);
testSession.setLastMessageTime(TimeUtil.getCurrentTimestamp());
testSession = sessionRepository.save(testSession);
// 创建测试消息
testMessage = new Message();
testMessage.setMessageId(UUID.randomUUID().toString());
testMessage.setSessionId(testSession.getSessionId());
testMessage.setUserId(testUser.getId());
testMessage.setRole("user");
testMessage.setContent("这是一条测试消息内容");
testMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
testMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
testMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
testMessage = messageRepository.save(testMessage);
}
@Test
void testAddFavorite() {
// 准备请求
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
// 添加收藏
ApiResponse<FavoriteResponse> response = favoriteService.addFavorite(testUser.getId(), request);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
FavoriteResponse favoriteResponse = response.getBody();
assertEquals(testMessage.getMessageId(), favoriteResponse.getMessageId());
assertEquals(testSession.getSessionId(), favoriteResponse.getSessionId());
assertEquals(testSession.getTitle(), favoriteResponse.getSessionTitle());
assertEquals(testMessage.getContent(), favoriteResponse.getMessageContent());
assertEquals(testMessage.getRole(), favoriteResponse.getMessageRole());
assertNotNull(favoriteResponse.getFavoriteId());
assertNotNull(favoriteResponse.getFavoritedAt());
// 验证数据库中的收藏记录
boolean exists = favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(
testUser.getId(), testMessage.getMessageId());
assertTrue(exists);
}
@Test
void testAddFavoriteDuplicate() {
// 先添加一次收藏
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request);
// 再次尝试添加收藏,应该失败
try {
favoriteService.addFavorite(testUser.getId(), request);
fail("应该抛出重复收藏的异常");
} catch (Exception e) {
assertTrue(e.getMessage().contains("已经收藏过了"));
}
}
@Test
void testRemoveFavorite() {
// 先添加收藏
AddFavoriteRequest addRequest = new AddFavoriteRequest();
addRequest.setMessageId(testMessage.getMessageId());
addRequest.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), addRequest);
// 准备取消收藏请求
RemoveFavoriteRequest removeRequest = new RemoveFavoriteRequest();
removeRequest.setMessageId(testMessage.getMessageId());
// 取消收藏
ApiResponse<Void> response = favoriteService.removeFavorite(testUser.getId(), removeRequest);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
// 验证收藏记录已被删除
boolean exists = favoriteRepository.existsByUserIdAndMessageIdAndNotDeleted(
testUser.getId(), testMessage.getMessageId());
assertFalse(exists);
}
@Test
void testGetFavoriteList() {
// 先添加几个收藏
AddFavoriteRequest request1 = new AddFavoriteRequest();
request1.setMessageId(testMessage.getMessageId());
request1.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request1);
// 创建第二条消息和收藏
Message message2 = new Message();
message2.setMessageId(UUID.randomUUID().toString());
message2.setSessionId(testSession.getSessionId());
message2.setUserId(testUser.getId());
message2.setRole("assistant");
message2.setContent("这是AI助手的回答");
message2.setTimestamp(TimeUtil.getCurrentTimestamp());
message2.setCreatedAt(TimeUtil.getCurrentTimestamp());
message2.setUpdatedAt(TimeUtil.getCurrentTimestamp());
messageRepository.save(message2);
AddFavoriteRequest request2 = new AddFavoriteRequest();
request2.setMessageId(message2.getMessageId());
request2.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request2);
// 准备查询请求
FavoriteListRequest listRequest = new FavoriteListRequest();
listRequest.setPage(1);
listRequest.setPageSize(10);
listRequest.setMessageRole("all");
// 获取收藏列表
ApiResponse<PageUtil.PageResponse<FavoriteResponse>> response =
favoriteService.getFavoriteList(testUser.getId(), listRequest);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
PageUtil.PageResponse<FavoriteResponse> pageResponse = response.getBody();
assertNotNull(pageResponse.getContent());
assertEquals(2, pageResponse.getContent().size());
assertEquals(2L, pageResponse.getTotal());
assertEquals(1, pageResponse.getPage());
assertEquals(10, pageResponse.getSize());
}
@Test
void testIsFavorited() {
// 检查未收藏的消息
ApiResponse<Boolean> response1 = favoriteService.isFavorited(testUser.getId(), testMessage.getMessageId());
assertNotNull(response1);
assertTrue(response1.getSuccess());
assertFalse(response1.getBody());
// 添加收藏
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request);
// 检查已收藏的消息
ApiResponse<Boolean> response2 = favoriteService.isFavorited(testUser.getId(), testMessage.getMessageId());
assertNotNull(response2);
assertTrue(response2.getSuccess());
assertTrue(response2.getBody());
}
@Test
void testGetUserFavoriteCount() {
// 初始收藏数量应该为0
ApiResponse<Long> response1 = favoriteService.getUserFavoriteCount(testUser.getId());
assertNotNull(response1);
assertTrue(response1.getSuccess());
assertEquals(0L, response1.getBody());
// 添加一个收藏
AddFavoriteRequest request = new AddFavoriteRequest();
request.setMessageId(testMessage.getMessageId());
request.setSessionId(testSession.getSessionId());
favoriteService.addFavorite(testUser.getId(), request);
// 收藏数量应该为1
ApiResponse<Long> response2 = favoriteService.getUserFavoriteCount(testUser.getId());
assertNotNull(response2);
assertTrue(response2.getSuccess());
assertEquals(1L, response2.getBody());
}
}

View File

@ -0,0 +1,176 @@
package com.lxy.hsend.service;
import com.lxy.hsend.common.ApiResponse;
import com.lxy.hsend.dto.message.MessageListRequest;
import com.lxy.hsend.dto.message.MessageResponse;
import com.lxy.hsend.dto.message.SendMessageRequest;
import com.lxy.hsend.entity.Message;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.repository.MessageRepository;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import com.lxy.hsend.util.PageUtil;
import com.lxy.hsend.util.TimeUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 消息服务测试
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MessageServiceTest {
@Autowired
private MessageService messageService;
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
@Autowired
private MessageRepository messageRepository;
private User testUser;
private Session testSession;
@BeforeEach
void setUp() {
// 创建测试用户
testUser = new User();
testUser.setEmail("test@example.com");
testUser.setNickname("testuser");
testUser.setPasswordHash("hashedpassword");
testUser.setStatus((byte) 1);
testUser = userRepository.save(testUser);
// 创建测试会话
testSession = new Session();
testSession.setSessionId(UUID.randomUUID().toString());
testSession.setUserId(testUser.getId());
testSession.setTitle("测试会话");
testSession.setMessageCount(0);
testSession.setLastMessageTime(TimeUtil.getCurrentTimestamp());
testSession = sessionRepository.save(testSession);
}
@Test
void testSendMessage() {
// 准备请求
SendMessageRequest request = new SendMessageRequest();
request.setSessionId(testSession.getSessionId());
request.setContent("你好,这是一个测试消息");
request.setDeepThinking(false);
request.setWebSearch(false);
// 发送消息
ApiResponse<MessageResponse> response = messageService.sendMessage(testUser.getId(), request);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
MessageResponse messageResponse = response.getBody();
assertEquals(testSession.getSessionId(), messageResponse.getSessionId());
assertEquals("assistant", messageResponse.getRole());
assertNotNull(messageResponse.getContent());
assertNotNull(messageResponse.getMessageId());
assertNotNull(messageResponse.getCreatedAt());
// 验证数据库中的消息
long messageCount = messageRepository.countBySessionIdAndNotDeleted(testSession.getSessionId());
assertEquals(2, messageCount); // 用户消息 + AI响应
}
@Test
void testGetMessages() {
// 先创建一些测试消息
Message userMessage = new Message();
userMessage.setMessageId(UUID.randomUUID().toString());
userMessage.setSessionId(testSession.getSessionId());
userMessage.setUserId(testUser.getId());
userMessage.setRole("user");
userMessage.setContent("用户消息");
userMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
userMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
userMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
messageRepository.save(userMessage);
Message aiMessage = new Message();
aiMessage.setMessageId(UUID.randomUUID().toString());
aiMessage.setSessionId(testSession.getSessionId());
aiMessage.setUserId(testUser.getId());
aiMessage.setRole("assistant");
aiMessage.setContent("AI响应");
aiMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
aiMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
aiMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
messageRepository.save(aiMessage);
// 准备请求
MessageListRequest request = new MessageListRequest();
request.setSessionId(testSession.getSessionId());
request.setPage(1);
request.setPageSize(10);
request.setRole("all");
// 获取消息列表
ApiResponse<PageUtil.PageResponse<MessageResponse>> response =
messageService.getMessages(testUser.getId(), request);
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
PageUtil.PageResponse<MessageResponse> pageResponse = response.getBody();
assertNotNull(pageResponse.getContent());
assertEquals(2, pageResponse.getContent().size());
assertEquals(2L, pageResponse.getTotal());
assertEquals(1, pageResponse.getPage());
assertEquals(10, pageResponse.getSize());
}
@Test
void testGetMessageById() {
// 创建测试消息
Message testMessage = new Message();
testMessage.setMessageId(UUID.randomUUID().toString());
testMessage.setSessionId(testSession.getSessionId());
testMessage.setUserId(testUser.getId());
testMessage.setRole("user");
testMessage.setContent("测试消息内容");
testMessage.setTimestamp(TimeUtil.getCurrentTimestamp());
testMessage.setCreatedAt(TimeUtil.getCurrentTimestamp());
testMessage.setUpdatedAt(TimeUtil.getCurrentTimestamp());
testMessage = messageRepository.save(testMessage);
// 获取消息详情
ApiResponse<MessageResponse> response =
messageService.getMessageById(testUser.getId(), testMessage.getMessageId());
// 验证响应
assertNotNull(response);
assertTrue(response.getSuccess());
assertNotNull(response.getBody());
MessageResponse messageResponse = response.getBody();
assertEquals(testMessage.getMessageId(), messageResponse.getMessageId());
assertEquals(testMessage.getSessionId(), messageResponse.getSessionId());
assertEquals(testMessage.getRole(), messageResponse.getRole());
assertEquals(testMessage.getContent(), messageResponse.getContent());
}
}

View File

@ -0,0 +1,168 @@
package com.lxy.hsend.service;
import com.lxy.hsend.dto.session.CreateSessionRequest;
import com.lxy.hsend.dto.session.SessionDetailResponse;
import com.lxy.hsend.dto.session.SessionSearchRequest;
import com.lxy.hsend.dto.session.UpdateSessionRequest;
import com.lxy.hsend.entity.Session;
import com.lxy.hsend.entity.User;
import com.lxy.hsend.repository.SessionRepository;
import com.lxy.hsend.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
/**
* 会话服务测试
*
* @author lxy
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@Transactional
public class SessionServiceTest {
@Autowired
private SessionService sessionService;
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
@Test
public void testSessionManagement() {
log.info("开始测试会话管理功能");
// 1. 创建测试用户
User testUser = createTestUser();
Long userId = testUser.getId();
// 2. 测试创建会话
CreateSessionRequest createRequest = new CreateSessionRequest();
createRequest.setTitle("测试会话标题");
createRequest.setInitialMessage("这是一条初始消息");
SessionDetailResponse createdSession = sessionService.createSession(userId, createRequest);
assertNotNull(createdSession);
assertNotNull(createdSession.getSessionId());
assertEquals("测试会话标题", createdSession.getTitle());
assertEquals(userId, createdSession.getUserId());
log.info("✅ 会话创建成功: sessionId={}", createdSession.getSessionId());
// 3. 测试获取会话详情
SessionDetailResponse sessionDetail = sessionService.getSessionDetail(userId, createdSession.getSessionId());
assertNotNull(sessionDetail);
assertEquals(createdSession.getSessionId(), sessionDetail.getSessionId());
assertEquals("测试会话标题", sessionDetail.getTitle());
log.info("✅ 获取会话详情成功");
// 4. 测试更新会话标题
UpdateSessionRequest updateRequest = new UpdateSessionRequest();
updateRequest.setTitle("更新后的标题");
SessionDetailResponse updatedSession = sessionService.updateSession(userId, createdSession.getSessionId(), updateRequest);
assertNotNull(updatedSession);
assertEquals("更新后的标题", updatedSession.getTitle());
log.info("✅ 会话标题更新成功");
// 5. 测试获取会话列表
SessionSearchRequest searchRequest = new SessionSearchRequest();
searchRequest.setPage(1);
searchRequest.setPageSize(10);
Page<com.lxy.hsend.dto.session.SessionListResponse> sessionList = sessionService.getSessionList(userId, searchRequest);
assertNotNull(sessionList);
assertTrue(sessionList.getTotalElements() >= 1);
log.info("✅ 获取会话列表成功,总数: {}", sessionList.getTotalElements());
// 6. 测试会话权限验证
boolean hasPermission = sessionService.hasSessionPermission(userId, createdSession.getSessionId());
assertTrue(hasPermission);
log.info("✅ 会话权限验证成功");
// 7. 测试清空会话
sessionService.clearSession(userId, createdSession.getSessionId());
log.info("✅ 会话清空成功");
// 8. 测试删除会话
sessionService.deleteSession(userId, createdSession.getSessionId());
log.info("✅ 会话删除成功");
// 验证会话已被软删除
boolean hasPermissionAfterDelete = sessionService.hasSessionPermission(userId, createdSession.getSessionId());
assertFalse(hasPermissionAfterDelete);
log.info("✅ 会话软删除验证成功");
System.out.println("✅ 所有会话管理功能测试通过!");
}
@Test
public void testSessionSearch() {
log.info("开始测试会话搜索功能");
// 创建测试用户
User testUser = createTestUser();
Long userId = testUser.getId();
// 创建多个测试会话
CreateSessionRequest request1 = new CreateSessionRequest();
request1.setTitle("Java开发学习");
sessionService.createSession(userId, request1);
CreateSessionRequest request2 = new CreateSessionRequest();
request2.setTitle("Python数据分析");
sessionService.createSession(userId, request2);
CreateSessionRequest request3 = new CreateSessionRequest();
request3.setTitle("前端React开发");
sessionService.createSession(userId, request3);
// 测试关键词搜索
SessionSearchRequest searchRequest = new SessionSearchRequest();
searchRequest.setKeyword("开发");
searchRequest.setPage(1);
searchRequest.setPageSize(10);
Page<com.lxy.hsend.dto.session.SessionListResponse> searchResult = sessionService.getSessionList(userId, searchRequest);
assertNotNull(searchResult);
assertTrue(searchResult.getTotalElements() >= 2); // 至少找到2个包含"开发"的会话
log.info("✅ 会话关键词搜索成功,找到 {} 个结果", searchResult.getTotalElements());
// 测试排序
searchRequest.setKeyword(null);
searchRequest.setSortBy("created_at");
searchRequest.setSortOrder("desc");
Page<com.lxy.hsend.dto.session.SessionListResponse> sortedResult = sessionService.getSessionList(userId, searchRequest);
assertNotNull(sortedResult);
log.info("✅ 会话排序功能测试成功");
System.out.println("✅ 会话搜索功能测试通过!");
}
private User createTestUser() {
User user = new User();
user.setEmail("session-test@example.com");
user.setPasswordHash("test-password-hash");
user.setNickname("会话测试用户");
user.setStatus((byte) 1);
user.setCreatedAt(System.currentTimeMillis());
user.setUpdatedAt(System.currentTimeMillis());
return userRepository.save(user);
}
}