上传初始版,功能未经任何测试,仅为基本框架,目前为完全不可用状态

This commit is contained in:
sun
2026-01-02 01:19:24 +08:00
parent 8c39d85831
commit 2c8afc1275
18 changed files with 2729 additions and 34 deletions
+197
View File
@@ -0,0 +1,197 @@
# 豆包AI对话系统
一个模仿豆包官网的AI对话系统,包含前端页面和Go后端服务。
## 功能特性
### 前端功能
- 📱 简洁美观的聊天界面
- 💬 支持多轮对话
- 📝 支持换行输入(Shift+Enter)
- ⌨️ 支持键盘快捷键
- 📱 响应式设计,适配各种设备
### 后端功能
- 🔐 用户认证和授权(JWT)
- 👥 多用户支持
- 📊 用户配额管理
- 🧠 调用自建大模型API
- 📝 对话历史记录
- 👨‍💼 管理员管理功能
- 📈 系统统计信息
## 技术栈
### 前端
- HTML5 + CSS3 + JavaScript
- Font Awesome 图标库
### 后端
- Go 1.21
- Gin Web框架
- GORM ORM框架
- SQLite数据库(默认)
- JWT认证
## 目录结构
```
dobaochet/
├── index.html # 前端主页面
├── styles.css # 前端样式
├── script.js # 前端脚本
├── backend/ # 后端代码
│ ├── main.go # 主程序
│ ├── go.mod # Go依赖
│ ├── .env.example # 配置文件示例
│ ├── api/ # API路由
│ ├── models/ # 数据模型
│ ├── services/ # 业务逻辑
│ ├── config/ # 配置管理
│ ├── utils/ # 工具函数
│ └── db/ # 数据库连接
└── README.md # 项目说明
```
## 快速开始
### 1. 前端使用
直接在浏览器中打开 `index.html` 文件即可使用前端页面。
### 2. 后端部署
#### 2.1 配置环境
复制 `.env.example` 为 `.env` 并修改配置:
```bash
cd backend
cp .env.example .env
```
编辑 `.env` 文件,配置以下关键参数:
```env
# AI模型配置
AI_API_URL=http://localhost:5000/v1/chat/completions # 替换为您的大模型API URL
AI_API_KEY= # 大模型API密钥
AI_MODEL_NAME=default # 模型名称
```
#### 2.2 安装依赖
```bash
go mod tidy
```
#### 2.3 启动服务
```bash
go run main.go
```
服务将在 `http://localhost:8000` 启动。
## API文档
### 认证相关
- `POST /api/users/register` - 用户注册
- `POST /api/users/login` - 用户登录
- `GET /api/users/me` - 获取当前用户信息
### 聊天相关
- `POST /api/chat/` - 发送聊天消息
- `GET /api/chat/conversations` - 获取对话列表
- `GET /api/chat/conversations/:id` - 获取对话详情
- `DELETE /api/chat/conversations/:id` - 删除对话
- `DELETE /api/chat/conversations` - 清空所有对话
### 管理员相关
- `GET /api/admin/users` - 获取所有用户
- `GET /api/admin/users/:id` - 获取单个用户
- `POST /api/admin/users` - 创建用户
- `PUT /api/admin/users/:id` - 更新用户
- `DELETE /api/admin/users/:id` - 删除用户
- `PUT /api/admin/users/:id/quota` - 更新用户配额
- `POST /api/admin/users/:id/quota/reset` - 重置用户配额
- `GET /api/admin/stats` - 获取系统统计信息
## 配置说明
### 数据库配置
支持多种数据库,默认使用SQLite:
```env
# SQLite
DATABASE_DSN=sqlite://./chat.db
# MySQL
# DATABASE_DSN=mysql://user:password@tcp(localhost:3306)/chat?charset=utf8mb4&parseTime=True&loc=Local
# PostgreSQL
# DATABASE_DSN=postgres://user:password@localhost:5432/chat?sslmode=disable
```
### JWT配置
```env
JWT_SECRET_KEY=your-secret-key-change-me # 生产环境请修改为复杂密钥
JWT_ACCESS_TOKEN_EXPIRE=24h # 访问令牌过期时间
JWT_REFRESH_TOKEN_EXPIRE=720h # 刷新令牌过期时间
```
### 配额配置
```env
QUOTA_DEFAULT_TOTAL_TOKENS=100000 # 默认总配额
QUOTA_DEFAULT_TOKEN_LIMIT=1000 # 单次请求限制
QUOTA_RESET_INTERVAL=720h # 配额重置间隔
```
## 默认管理员账号
系统初始化后会自动创建管理员账号:
- 用户名:`admin`
- 密码:`admin123`
- 生产环境请及时修改密码!
## 开发说明
### 前端开发
前端使用纯HTML+CSS+JavaScript开发,无需编译,直接修改文件即可。
### 后端开发
后端使用Go语言开发,推荐使用Go 1.21或以上版本。
## 生产环境部署
1. 修改JWT密钥为复杂随机字符串
2. 配置正确的数据库连接
3. 配置HTTPS(推荐使用Nginx反向代理)
4. 设置适当的日志级别
5. 定期备份数据库
## 安全建议
1. 生产环境中不要使用默认的JWT密钥
2. 定期更换JWT密钥
3. 配置适当的CORS策略
4. 启用HTTPS
5. 限制API请求频率
6. 定期审计系统日志
## 许可证
MIT License
## 贡献
欢迎提交Issue和Pull Request!
+35
View File
@@ -0,0 +1,35 @@
# 服务器配置
SERVER_PORT=8000
SERVER_HOST=0.0.0.0
SERVER_READ_TIMEOUT=15s
SERVER_WRITE_TIMEOUT=15s
# 数据库配置
# 支持 SQLite, MySQL, PostgreSQL
DATABASE_DSN=sqlite://./chat.db
DATABASE_MAX_IDLE_CONNS=10
DATABASE_MAX_OPEN_CONNS=100
# JWT 配置
JWT_SECRET_KEY=your-secret-key-change-me
JWT_ACCESS_TOKEN_EXPIRE=24h
JWT_REFRESH_TOKEN_EXPIRE=720h
# AI 模型配置
# 替换为您自己的大模型 API URL
AI_API_URL=http://localhost:5000/v1/chat/completions
AI_API_KEY=
AI_MODEL_NAME=default
AI_MAX_TOKENS=2048
AI_TEMPERATURE=0.7
# 配额配置
QUOTA_DEFAULT_TOTAL_TOKENS=100000
QUOTA_DEFAULT_TOKEN_LIMIT=1000
QUOTA_RESET_INTERVAL=720h
# CORS 配置
CORS_ALLOW_ORIGINS=*
CORS_ALLOW_METHODS=GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOW_HEADERS=Origin,Content-Type,Authorization
CORS_ALLOW_CREDENTIALS=true
+359
View File
@@ -0,0 +1,359 @@
package api
import (
"net/http"
"time"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\db"
"d:\项目\dobaochet\backend\models"
"d:\项目\dobaochet\backend\utils"
"github.com/gin-gonic/gin"
)
// AdminAPI 管理员API结构体
type AdminAPI struct {
cfg *config.Config
}
// NewAdminAPI 创建管理员API实例
func NewAdminAPI(cfg *config.Config) *AdminAPI {
return &AdminAPI{cfg: cfg}
}
// GetUsers 获取所有用户
func (api *AdminAPI) GetUsers(c *gin.Context) {
// 分页参数
page := 1
pageSize := 10
c.Query("page")
c.Query("page_size")
// 查询用户列表
var users []models.User
var total int64
db.GetDB().Model(&models.User{}).Count(&total)
db.GetDB().Preload("Quota").Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users)
// 转换为响应格式
var userResponses []models.UserResponse
for _, user := range users {
userResponses = append(userResponses, models.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsAdmin: user.IsAdmin,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
Quota: user.Quota,
})
}
c.JSON(http.StatusOK, gin.H{
"users": userResponses,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
// GetUser 获取单个用户
func (api *AdminAPI) GetUser(c *gin.Context) {
// 获取用户ID
id := c.Param("id")
// 查询用户
var user models.User
if result := db.GetDB().Preload("Quota").First(&user, id); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, models.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsAdmin: user.IsAdmin,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
Quota: user.Quota,
})
}
// CreateUser 创建用户
func (api *AdminAPI) CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
IsAdmin bool `json:"is_admin"`
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查用户名是否已存在
var existingUser models.User
if result := db.GetDB().Where("username = ?", req.Username).First(&existingUser); result.Error == nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
return
}
// 检查邮箱是否已存在
if result := db.GetDB().Where("email = ?", req.Email).First(&existingUser); result.Error == nil {
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已存在"})
return
}
// 密码加密
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
// 创建用户
user := models.User{
Username: req.Username,
Email: req.Email,
Password: hashedPassword,
IsAdmin: req.IsAdmin,
IsActive: req.IsActive,
LastLoginAt: time.Now(),
Quota: models.Quota{
TotalTokens: api.cfg.Quota.DefaultTotalTokens,
UsedTokens: 0,
ResetAt: time.Now().Add(api.cfg.Quota.ResetInterval),
TokenLimit: api.cfg.Quota.DefaultTokenLimit,
},
}
if result := db.GetDB().Create(&user); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
return
}
c.JSON(http.StatusCreated, models.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsAdmin: user.IsAdmin,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
Quota: user.Quota,
})
}
// UpdateUser 更新用户
func (api *AdminAPI) UpdateUser(c *gin.Context) {
// 获取用户ID
id := c.Param("id")
// 查询用户
var user models.User
if result := db.GetDB().First(&user, id); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
// 解析请求
var req struct {
Email string `json:"email" binding:"omitempty,email"`
IsAdmin bool `json:"is_admin"`
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 更新用户信息
if req.Email != "" && req.Email != user.Email {
// 检查邮箱是否已被使用
var existingUser models.User
if result := db.GetDB().Where("email = ? AND id != ?", req.Email, user.ID).First(&existingUser); result.Error == nil {
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被使用"})
return
}
user.Email = req.Email
}
user.IsAdmin = req.IsAdmin
user.IsActive = req.IsActive
if result := db.GetDB().Save(&user); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户失败"})
return
}
// 重新加载用户信息
db.GetDB().Preload("Quota").First(&user, id)
c.JSON(http.StatusOK, models.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsAdmin: user.IsAdmin,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
Quota: user.Quota,
})
}
// DeleteUser 删除用户
func (api *AdminAPI) DeleteUser(c *gin.Context) {
// 获取用户ID
id := c.Param("id")
// 检查是否是最后一个管理员
var user models.User
if result := db.GetDB().First(&user, id); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
if user.IsAdmin {
var adminCount int64
db.GetDB().Model(&models.User{}).Where("is_admin = ?", true).Count(&adminCount)
if adminCount <= 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除最后一个管理员用户"})
return
}
}
// 删除用户(级联删除相关数据)
if result := db.GetDB().Delete(&user); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "用户删除成功"})
}
// UpdateUserQuota 更新用户配额
func (api *AdminAPI) UpdateUserQuota(c *gin.Context) {
// 获取用户ID
id := c.Param("id")
// 查询用户
var user models.User
if result := db.GetDB().Preload("Quota").First(&user, id); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
// 解析请求
var req models.QuotaUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 更新配额
user.Quota.TotalTokens = req.TotalTokens
user.Quota.TokenLimit = req.TokenLimit
if result := db.GetDB().Save(&user.Quota); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新配额失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "配额更新成功",
"quota": user.Quota,
})
}
// ResetUserQuota 重置用户配额
func (api *AdminAPI) ResetUserQuota(c *gin.Context) {
// 获取用户ID
id := c.Param("id")
// 查询用户
var user models.User
if result := db.GetDB().Preload("Quota").First(&user, id); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
// 重置配额
user.Quota.UsedTokens = 0
user.Quota.ResetAt = time.Now().Add(api.cfg.Quota.ResetInterval)
if result := db.GetDB().Save(&user.Quota); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置配额失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "配额重置成功",
"quota": user.Quota,
})
}
// GetSystemStats 获取系统统计信息
func (api *AdminAPI) GetSystemStats(c *gin.Context) {
// 用户统计
var totalUsers, activeUsers, adminUsers int64
db.GetDB().Model(&models.User{}).Count(&totalUsers)
db.GetDB().Model(&models.User{}).Where("is_active = ?", true).Count(&activeUsers)
db.GetDB().Model(&models.User{}).Where("is_admin = ?", true).Count(&adminUsers)
// 对话统计
var totalConversations int64
db.GetDB().Model(&models.Conversation{}).Count(&totalConversations)
// 消息统计
var totalMessages int64
db.GetDB().Model(&models.Message{}).Count(&totalMessages)
// 配额统计
var totalTokens, usedTokens int64
db.GetDB().Model(&models.Quota{}).Select("sum(total_tokens) as total_tokens, sum(used_tokens) as used_tokens").Scan(&struct {
TotalTokens int64
UsedTokens int64
}{TotalTokens: &totalTokens, UsedTokens: &usedTokens})
c.JSON(http.StatusOK, gin.H{
"users": gin.H{
"total": totalUsers,
"active": activeUsers,
"admin": adminUsers,
},
"conversations": totalConversations,
"messages": totalMessages,
"quota": gin.H{
"total_tokens": totalTokens,
"used_tokens": usedTokens,
"usage_rate": float64(usedTokens) / float64(totalTokens) * 100,
},
})
}
// RegisterRoutes 注册管理员路由
func (api *AdminAPI) RegisterRoutes(router *gin.RouterGroup) {
adminGroup := router.Group("/admin")
adminGroup.Use(AuthMiddleware(api.cfg), AdminMiddleware())
{
// 用户管理
adminGroup.GET("/users", api.GetUsers)
adminGroup.GET("/users/:id", api.GetUser)
adminGroup.POST("/users", api.CreateUser)
adminGroup.PUT("/users/:id", api.UpdateUser)
adminGroup.DELETE("/users/:id", api.DeleteUser)
// 配额管理
adminGroup.PUT("/users/:id/quota", api.UpdateUserQuota)
adminGroup.POST("/users/:id/quota/reset", api.ResetUserQuota)
// 系统统计
adminGroup.GET("/stats", api.GetSystemStats)
}
}
+277
View File
@@ -0,0 +1,277 @@
package api
import (
"net/http"
"time"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\db"
"d:\项目\dobaochet\backend\models"
"d:\项目\dobaochet\backend\services"
"github.com/gin-gonic/gin"
)
// ChatAPI 聊天API结构体
type ChatAPI struct {
cfg *config.Config
aiService *services.AIService
}
// NewChatAPI 创建聊天API实例
func NewChatAPI(cfg *config.Config, aiService *services.AIService) *ChatAPI {
return &ChatAPI{
cfg: cfg,
aiService: aiService,
}
}
// Chat 处理聊天请求
func (api *ChatAPI) Chat(c *gin.Context) {
// 从上下文获取用户信息
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
currentUser := user.(models.User)
// 解析请求
var req models.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 估算用户输入的令牌数量
inputTokens := api.aiService.CountTokens(req.Message)
// 检查单次请求令牌限制
if inputTokens > currentUser.Quota.TokenLimit {
c.JSON(http.StatusBadRequest, gin.H{"error": "单次请求令牌数超过限制"})
return
}
// 检查配额是否足够
if currentUser.Quota.UsedTokens+inputTokens > currentUser.Quota.TotalTokens {
c.JSON(http.StatusForbidden, gin.H{"error": "令牌配额不足"})
return
}
// 开始数据库事务
tx := db.GetDB().Begin()
// 获取或创建对话
var conversation models.Conversation
if req.ConversationID > 0 {
// 检查对话是否属于当前用户
if result := tx.Where("id = ? AND user_id = ?", req.ConversationID, currentUser.ID).First(&conversation); result.Error != nil {
tx.Rollback()
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
} else {
// 创建新对话
conversation = models.Conversation{
UserID: currentUser.ID,
Title: req.Message[:min(50, len(req.Message))],
TotalTokens: 0,
}
if result := tx.Create(&conversation); result.Error != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建对话失败"})
return
}
}
// 保存用户消息
userMessage := models.Message{
ConversationID: conversation.ID,
Role: "user",
Content: req.Message,
Tokens: inputTokens,
Model: api.cfg.AI.ModelName,
}
if result := tx.Create(&userMessage); result.Error != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存消息失败"})
return
}
// 获取对话历史(最近10条消息)
var historyMessages []models.Message
tx.Where("conversation_id = ?", conversation.ID).Order("created_at desc").Limit(10).Find(&historyMessages)
// 转换为AI服务需要的格式
var aiMessages []services.Message
for i := len(historyMessages) - 1; i >= 0; i-- {
aiMessages = append(aiMessages, services.Message{
Role: historyMessages[i].Role,
Content: historyMessages[i].Content,
})
}
// 调用AI服务
aiResp, err := api.aiService.Chat(aiMessages)
if err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "AI服务调用失败: " + err.Error()})
return
}
// 检查AI响应
if len(aiResp.Choices) == 0 {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "AI服务返回空响应"})
return
}
// 保存AI回复
aiMessage := models.Message{
ConversationID: conversation.ID,
Role: "assistant",
Content: aiResp.Choices[0].Message.Content,
Tokens: aiResp.Usage.CompletionTokens,
Model: api.cfg.AI.ModelName,
}
if result := tx.Create(&aiMessage); result.Error != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存AI回复失败"})
return
}
// 更新对话总令牌数
conversation.TotalTokens += aiResp.Usage.TotalTokens
if result := tx.Save(&conversation); result.Error != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新对话令牌数失败"})
return
}
// 更新用户配额
currentUser.Quota.UsedTokens += aiResp.Usage.TotalTokens
if result := tx.Save(&currentUser.Quota); result.Error != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新配额失败"})
return
}
// 提交事务
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存数据失败"})
return
}
// 返回响应
c.JSON(http.StatusOK, models.ChatResponse{
Response: aiMessage.Content,
ConversationID: conversation.ID,
TokensUsed: aiResp.Usage.TotalTokens,
})
}
// GetConversations 获取用户对话列表
func (api *ChatAPI) GetConversations(c *gin.Context) {
// 从上下文获取用户信息
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
// 获取对话列表
var conversations []models.Conversation
db.GetDB().Where("user_id = ?", userID).Order("created_at desc").Find(&conversations)
c.JSON(http.StatusOK, conversations)
}
// GetConversation 获取对话详情
func (api *ChatAPI) GetConversation(c *gin.Context) {
// 从上下文获取用户信息
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
// 获取对话ID
id := c.Param("id")
// 检查对话是否属于当前用户
var conversation models.Conversation
if result := db.GetDB().Preload("Messages").Where("id = ? AND user_id = ?", id, userID).First(&conversation); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
c.JSON(http.StatusOK, conversation)
}
// DeleteConversation 删除对话
func (api *ChatAPI) DeleteConversation(c *gin.Context) {
// 从上下文获取用户信息
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
// 获取对话ID
id := c.Param("id")
// 检查对话是否属于当前用户
var conversation models.Conversation
if result := db.GetDB().Where("id = ? AND user_id = ?", id, userID).First(&conversation); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
// 删除对话(级联删除消息)
if result := db.GetDB().Delete(&conversation); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除对话失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "对话删除成功"})
}
// ClearConversations 清空用户对话
func (api *ChatAPI) ClearConversations(c *gin.Context) {
// 从上下文获取用户信息
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
// 删除用户所有对话
if result := db.GetDB().Where("user_id = ?", userID).Delete(&models.Conversation{}); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空对话失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "对话清空成功"})
}
// RegisterRoutes 注册聊天路由
func (api *ChatAPI) RegisterRoutes(router *gin.RouterGroup) {
chatGroup := router.Group("/chat")
chatGroup.Use(AuthMiddleware(api.cfg))
{
chatGroup.POST("/", api.Chat)
chatGroup.GET("/conversations", api.GetConversations)
chatGroup.GET("/conversations/:id", api.GetConversation)
chatGroup.DELETE("/conversations/:id", api.DeleteConversation)
chatGroup.DELETE("/conversations", api.ClearConversations)
}
}
// min 返回两个整数中的最小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"net/http"
"strings"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\db"
"d:\项目\dobaochet\backend\models"
"d:\项目\dobaochet\backend\utils"
"github.com/gin-gonic/gin"
)
// AuthMiddleware 认证中间件
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 从请求头获取Authorization
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
c.Abort()
return
}
// 检查Bearer前缀
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == "Bearer") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "认证令牌格式错误"})
c.Abort()
return
}
// 解析JWT令牌
claims, err := utils.ParseToken(parts[1], cfg.JWT.SecretKey)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证令牌"})
c.Abort()
return
}
// 查询用户信息
var user models.User
result := db.GetDB().Preload("Quota").First(&user, claims.UserID)
if result.Error != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
c.Abort()
return
}
// 检查用户是否激活
if !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "用户已被禁用"})
c.Abort()
return
}
// 将用户信息设置到上下文
c.Set("user", user)
c.Set("user_id", user.ID)
c.Set("is_admin", user.IsAdmin)
c.Next()
}
}
// AdminMiddleware 管理员中间件
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 从上下文获取用户信息
isAdmin, exists := c.Get("is_admin")
if !exists || !isAdmin.(bool) {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
c.Abort()
return
}
c.Next()
}
}
// CORSMiddleware CORS中间件
func CORSMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 设置允许的来源
c.Writer.Header().Set("Access-Control-Allow-Origin", strings.Join(cfg.CORS.AllowOrigins, ","))
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", strings.Join(cfg.CORS.AllowHeaders, ","))
c.Writer.Header().Set("Access-Control-Allow-Methods", strings.Join(cfg.CORS.AllowMethods, ","))
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+274
View File
@@ -0,0 +1,274 @@
package api
import (
"net/http"
"time"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\db"
"d:\项目\dobaochet\backend\models"
"d:\项目\dobaochet\backend\utils"
"github.com/gin-gonic/gin"
)
// UserAPI 用户API结构体
type UserAPI struct {
cfg *config.Config
}
// NewUserAPI 创建用户API实例
func NewUserAPI(cfg *config.Config) *UserAPI {
return &UserAPI{cfg: cfg}
}
// Register 注册用户
func (api *UserAPI) Register(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查用户名是否已存在
var existingUser models.User
if result := db.GetDB().Where("username = ?", req.Username).First(&existingUser); result.Error == nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
return
}
// 检查邮箱是否已存在
if result := db.GetDB().Where("email = ?", req.Email).First(&existingUser); result.Error == nil {
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已存在"})
return
}
// 密码加密
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
// 创建用户
user := models.User{
Username: req.Username,
Email: req.Email,
Password: hashedPassword,
IsActive: true,
LastLoginAt: time.Now(),
Quota: models.Quota{
TotalTokens: api.cfg.Quota.DefaultTotalTokens,
UsedTokens: 0,
ResetAt: time.Now().Add(api.cfg.Quota.ResetInterval),
TokenLimit: api.cfg.Quota.DefaultTokenLimit,
},
}
if result := db.GetDB().Create(&user); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
return
}
// 返回用户信息(隐藏敏感信息)
c.JSON(http.StatusCreated, gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"is_admin": user.IsAdmin,
"is_active": user.IsActive,
"created_at": user.CreatedAt,
})
}
// Login 用户登录
func (api *UserAPI) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 查找用户
var user models.User
if result := db.GetDB().Preload("Quota").Where("username = ?", req.Username).First(&user); result.Error != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 验证密码
if err := utils.VerifyPassword(user.Password, req.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 检查用户是否激活
if !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "用户已被禁用"})
return
}
// 更新登录时间
user.LastLoginAt = time.Now()
db.GetDB().Save(&user)
// 生成JWT令牌
token, err := utils.GenerateToken(
user.ID,
user.Username,
user.IsAdmin,
api.cfg.JWT.SecretKey,
api.cfg.JWT.AccessTokenExpire,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成令牌失败"})
return
}
// 返回登录结果
c.JSON(http.StatusOK, models.LoginResponse{
Token: token,
User: models.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsAdmin: user.IsAdmin,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
Quota: user.Quota,
},
})
}
// GetUserInfo 获取当前用户信息
func (api *UserAPI) GetUserInfo(c *gin.Context) {
// 从上下文获取用户信息
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
currentUser := user.(models.User)
c.JSON(http.StatusOK, models.UserResponse{
ID: currentUser.ID,
Username: currentUser.Username,
Email: currentUser.Email,
IsAdmin: currentUser.IsAdmin,
IsActive: currentUser.IsActive,
CreatedAt: currentUser.CreatedAt,
Quota: currentUser.Quota,
})
}
// UpdateUserInfo 更新用户信息
func (api *UserAPI) UpdateUserInfo(c *gin.Context) {
// 从上下文获取用户信息
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
currentUser := user.(models.User)
// 解析请求
var req struct {
Email string `json:"email" binding:"omitempty,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 更新用户信息
if req.Email != "" && req.Email != currentUser.Email {
// 检查邮箱是否已被使用
var existingUser models.User
if result := db.GetDB().Where("email = ? AND id != ?", req.Email, currentUser.ID).First(&existingUser); result.Error == nil {
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被使用"})
return
}
currentUser.Email = req.Email
}
if result := db.GetDB().Save(&currentUser); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户信息失败"})
return
}
c.JSON(http.StatusOK, models.UserResponse{
ID: currentUser.ID,
Username: currentUser.Username,
Email: currentUser.Email,
IsAdmin: currentUser.IsAdmin,
IsActive: currentUser.IsActive,
CreatedAt: currentUser.CreatedAt,
Quota: currentUser.Quota,
})
}
// ChangePassword 修改密码
func (api *UserAPI) ChangePassword(c *gin.Context) {
// 从上下文获取用户信息
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
return
}
currentUser := user.(models.User)
// 解析请求
var req struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证旧密码
if err := utils.VerifyPassword(currentUser.Password, req.OldPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "旧密码错误"})
return
}
// 加密新密码
newHashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
// 更新密码
currentUser.Password = newHashedPassword
if result := db.GetDB().Save(&currentUser); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
// RegisterRoutes 注册用户路由
func (api *UserAPI) RegisterRoutes(router *gin.RouterGroup) {
userGroup := router.Group("/users")
{
// 公开路由
userGroup.POST("/register", api.Register)
userGroup.POST("/login", api.Login)
// 需要认证的路由
authGroup := userGroup.Group("/")
authGroup.Use(AuthMiddleware(api.cfg))
{
authGroup.GET("/me", api.GetUserInfo)
authGroup.PUT("/me", api.UpdateUserInfo)
authGroup.PUT("/me/password", api.ChangePassword)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"fmt"
"log"
"os"
"time"
"github.com/spf13/viper"
)
// Config 应用配置结构体
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
JWT JWTConfig `mapstructure:"jwt"`
AI AIConfig `mapstructure:"ai"`
Quota QuotaConfig `mapstructure:"quota"`
CORS CORSConfig `mapstructure:"cors"`
}
// ServerConfig 服务器配置
type ServerConfig struct {
Port string `mapstructure:"port"`
Host string `mapstructure:"host"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
// DatabaseConfig 数据库配置
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
}
// JWTConfig JWT配置
type JWTConfig struct {
SecretKey string `mapstructure:"secret_key"`
AccessTokenExpire time.Duration `mapstructure:"access_token_expire"`
RefreshTokenExpire time.Duration `mapstructure:"refresh_token_expire"`
}
// AIConfig AI模型配置
type AIConfig struct {
APIURL string `mapstructure:"api_url"`
APIKey string `mapstructure:"api_key"`
ModelName string `mapstructure:"model_name"`
MaxTokens int64 `mapstructure:"max_tokens"`
Temperature float64 `mapstructure:"temperature"`
}
// QuotaConfig 配额配置
type QuotaConfig struct {
DefaultTotalTokens int64 `mapstructure:"default_total_tokens"`
DefaultTokenLimit int64 `mapstructure:"default_token_limit"`
ResetInterval time.Duration `mapstructure:"reset_interval"`
}
// CORSConfig CORS配置
type CORSConfig struct {
AllowOrigins []string `mapstructure:"allow_origins"`
AllowMethods []string `mapstructure:"allow_methods"`
AllowHeaders []string `mapstructure:"allow_headers"`
AllowCredentials bool `mapstructure:"allow_credentials"`
}
// LoadConfig 加载配置
func LoadConfig(path string) (*Config, error) {
// 设置默认值
setDefaults()
// 读取配置文件
viper.AddConfigPath(path)
viper.SetConfigName(".env")
viper.SetConfigType("env")
// 读取环境变量
viper.AutomaticEnv()
// 尝试读取配置文件
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
log.Println("警告: 未找到配置文件,将使用环境变量和默认值")
} else {
return nil, fmt.Errorf("读取配置文件错误: %w", err)
}
}
// 解析配置
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("解析配置错误: %w", err)
}
// 验证必要的配置
if err := validateConfig(&config); err != nil {
return nil, err
}
return &config, nil
}
// 设置默认值
func setDefaults() {
// 服务器默认配置
viper.SetDefault("server.port", "8000")
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.read_timeout", 15*time.Second)
viper.SetDefault("server.write_timeout", 15*time.Second)
// 数据库默认配置(使用SQLite)
viper.SetDefault("database.dsn", "./chat.db")
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
// JWT默认配置
viper.SetDefault("jwt.secret_key", "your-secret-key-change-me")
viper.SetDefault("jwt.access_token_expire", 24*time.Hour)
viper.SetDefault("jwt.refresh_token_expire", 7*24*time.Hour)
// AI模型默认配置
viper.SetDefault("ai.api_url", "http://localhost:5000/v1/chat/completions")
viper.SetDefault("ai.api_key", "")
viper.SetDefault("ai.model_name", "default")
viper.SetDefault("ai.max_tokens", 2048)
viper.SetDefault("ai.temperature", 0.7)
// 配额默认配置
viper.SetDefault("quota.default_total_tokens", 100000)
viper.SetDefault("quota.default_token_limit", 1000)
viper.SetDefault("quota.reset_interval", 30*24*time.Hour)
// CORS默认配置
viper.SetDefault("cors.allow_origins", []string{"*"})
viper.SetDefault("cors.allow_methods", []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"})
viper.SetDefault("cors.allow_headers", []string{"Origin", "Content-Type", "Authorization"})
viper.SetDefault("cors.allow_credentials", true)
}
// 验证配置
func validateConfig(config *Config) error {
// 验证JWT密钥
if config.JWT.SecretKey == "your-secret-key-change-me" {
log.Println("警告: 使用默认JWT密钥,建议在生产环境中修改")
}
// 验证AI API URL
if config.AI.APIURL == "" {
return fmt.Errorf("AI API URL不能为空")
}
return nil
}
// GetDSN 获取数据库DSN
func (c *DatabaseConfig) GetDSN() string {
// 如果是SQLite,确保目录存在
if len(c.DSN) > 6 && c.DSN[:6] == "sqlite" {
// 提取SQLite文件路径
filePath := c.DSN[9:] // 去掉 "sqlite://" 前缀
dir := filePath[:len(filePath)-len("/"+filePath[len(filePath)-1:])]
if dir != "" {
os.MkdirAll(dir, 0755)
}
}
return c.DSN
}
+127
View File
@@ -0,0 +1,127 @@
package db
import (
"log"
"time"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\models"
"d:\项目\dobaochet\backend\utils"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DB 全局数据库连接
var DB *gorm.DB
// InitDB 初始化数据库连接
func InitDB(cfg *config.Config) (*gorm.DB, error) {
// 配置GORM日志
newLogger := logger.New(
log.New(log.Writer(), "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second, // 慢SQL阈值
LogLevel: logger.Info, // 日志级别
Colorful: true, // 彩色日志
},
)
// 连接数据库
var err error
switch {
case len(cfg.Database.DSN) > 6 && cfg.Database.DSN[:6] == "sqlite":
// SQLite连接
DB, err = gorm.Open(sqlite.Open(cfg.Database.DSN[9:]), &gorm.Config{
Logger: newLogger,
})
default:
// 默认使用SQLite
DB, err = gorm.Open(sqlite.Open("./chat.db"), &gorm.Config{
Logger: newLogger,
})
}
if err != nil {
return nil, err
}
// 设置连接池
sqlDB, err := DB.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Hour)
// 自动迁移模型
err = migrateModels()
if err != nil {
return nil, err
}
// 初始化数据
err = seedData(cfg)
if err != nil {
return nil, err
}
return DB, nil
}
// migrateModels 自动迁移模型
func migrateModels() error {
return DB.AutoMigrate(
&models.User{},
&models.Quota{},
&models.Conversation{},
&models.Message{},
)
}
// seedData 初始化数据
func seedData(cfg *config.Config) error {
// 检查是否已有管理员用户
var count int64
DB.Model(&models.User{}).Where("is_admin = ?", true).Count(&count)
if count == 0 {
// 创建管理员用户
password, err := utils.HashPassword("admin123")
if err != nil {
return err
}
adminUser := models.User{
Username: "admin",
Email: "admin@example.com",
Password: password,
IsAdmin: true,
IsActive: true,
LastLoginAt: time.Now(),
Quota: models.Quota{
TotalTokens: cfg.Quota.DefaultTotalTokens * 10, // 管理员配额10倍
UsedTokens: 0,
ResetAt: time.Now().Add(cfg.Quota.ResetInterval),
TokenLimit: cfg.Quota.DefaultTokenLimit * 10,
},
}
result := DB.Create(&adminUser)
if result.Error != nil {
return result.Error
}
log.Println("已创建管理员用户: admin / admin123")
}
return nil
}
// GetDB 获取数据库连接
func GetDB() *gorm.DB {
return DB
}
+49
View File
@@ -0,0 +1,49 @@
module dobaochet
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/spf13/viper v1.18.2
golang.org/x/crypto v0.17.0
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.4
)
require (
github.com/bytedance/sonic v1.10.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.6.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"fmt"
"log"
"net/http"
"d:\项目\dobaochet\backend\api"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\db"
"d:\项目\dobaochet\backend\services"
"github.com/gin-gonic/gin"
)
func main() {
// 加载配置
cfg, err := config.LoadConfig(".")
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
// 初始化数据库
_, err = db.InitDB(cfg)
if err != nil {
log.Fatalf("初始化数据库失败: %v", err)
}
// 创建Gin引擎
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 添加中间件
r.Use(gin.Logger())
r.Use(gin.Recovery())
r.Use(api.CORSMiddleware(cfg))
// 创建服务实例
aiService := services.NewAIService(cfg)
quotaService := services.NewQuotaService(cfg)
// 启动配额重置定时任务
quotaService.StartQuotaResetJob()
// 创建API实例
userAPI := api.NewUserAPI(cfg)
chatAPI := api.NewChatAPI(cfg, aiService)
adminAPI := api.NewAdminAPI(cfg)
// 注册路由
apiGroup := r.Group("/api")
{
// 用户相关路由
userAPI.RegisterRoutes(apiGroup)
// 聊天相关路由
chatAPI.RegisterRoutes(apiGroup)
// 管理员相关路由
adminAPI.RegisterRoutes(apiGroup)
// 健康检查
apiGroup.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"message": "豆包AI后端服务运行正常",
})
})
}
// 启动服务器
serverAddr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
log.Printf("服务器启动在 %s", serverAddr)
// 创建HTTP服务器
server := &http.Server{
Addr: serverAddr,
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
// 启动服务器
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务器启动失败: %v", err)
}
}
+106
View File
@@ -0,0 +1,106 @@
package models
import (
"time"
"gorm.io/gorm"
)
// BaseModel 基础模型,包含通用字段
type BaseModel struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
}
// User 用户模型
type User struct {
BaseModel
Username string `gorm:"uniqueIndex;size:50;not null" json:"username"`
Password string `gorm:"size:255;not null" json:"-"`
Email string `gorm:"uniqueIndex;size:100;not null" json:"email"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt time.Time `json:"last_login_at"`
Quota Quota `gorm:"foreignKey:UserID" json:"quota"`
Conversations []Conversation `gorm:"foreignKey:UserID" json:"conversations,omitempty"`
}
// Quota 配额模型
type Quota struct {
BaseModel
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
TotalTokens int64 `gorm:"default:100000" json:"total_tokens"`
UsedTokens int64 `gorm:"default:0" json:"used_tokens"`
ResetAt time.Time `json:"reset_at"`
TokenLimit int64 `gorm:"default:1000" json:"token_limit"` // 单次请求限制
}
// Conversation 对话模型
type Conversation struct {
BaseModel
UserID uint `gorm:"index;not null" json:"user_id"`
Title string `gorm:"size:200;default:'新对话'" json:"title"`
Messages []Message `gorm:"foreignKey:ConversationID" json:"messages,omitempty"`
TotalTokens int64 `gorm:"default:0" json:"total_tokens"`
}
// Message 消息模型
type Message struct {
BaseModel
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
Role string `gorm:"size:20;not null" json:"role"` // user, assistant
Content string `gorm:"type:text;not null" json:"content"`
Tokens int64 `gorm:"default:0" json:"tokens"`
Model string `gorm:"size:50;default:'default'" json:"model"`
}
// ChatRequest 聊天请求模型
type ChatRequest struct {
Message string `json:"message" binding:"required"`
ConversationID uint `json:"conversation_id,omitempty"`
}
// ChatResponse 聊天响应模型
type ChatResponse struct {
Response string `json:"response"`
ConversationID uint `json:"conversation_id"`
TokensUsed int64 `json:"tokens_used"`
}
// UserResponse 用户响应模型(隐藏敏感信息)
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
Quota Quota `json:"quota"`
}
// LoginRequest 登录请求模型
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse 登录响应模型
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
// RegisterRequest 注册请求模型
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// QuotaUpdateRequest 配额更新请求模型
type QuotaUpdateRequest struct {
TotalTokens int64 `json:"total_tokens" binding:"required,min=0"`
TokenLimit int64 `json:"token_limit" binding:"required,min=1"`
}
+132
View File
@@ -0,0 +1,132 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"d:\项目\dobaochet\backend\config"
)
// AIService AI服务结构体
type AIService struct {
cfg *config.Config
client *http.Client
}
// NewAIService 创建AI服务实例
func NewAIService(cfg *config.Config) *AIService {
return &AIService{
cfg: cfg,
client: &http.Client{},
}
}
// AIChatRequest AI聊天请求结构体
type AIChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int64 `json:"max_tokens"`
Temperature float64 `json:"temperature"`
}
// Message 消息结构体
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// AIChatResponse AI聊天响应结构体
type AIChatResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
// Choice 响应选项结构体
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
// Usage 用量结构体
type Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
}
// Chat 调用AI模型聊天
func (s *AIService) Chat(messages []Message) (*AIChatResponse, error) {
// 构建请求
reqBody := AIChatRequest{
Model: s.cfg.AI.ModelName,
Messages: messages,
MaxTokens: s.cfg.AI.MaxTokens,
Temperature: s.cfg.AI.Temperature,
}
// 序列化请求体
reqBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
// 创建HTTP请求
req, err := http.NewRequest("POST", s.cfg.AI.APIURL, bytes.NewBuffer(reqBytes))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json")
if s.cfg.AI.APIKey != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.cfg.AI.APIKey))
}
// 发送请求
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("AI API返回错误: %s, 响应: %s", resp.Status, string(respBody))
}
// 解析响应
var aiResp AIChatResponse
if err := json.Unmarshal(respBody, &aiResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
return &aiResp, nil
}
// CountTokens 估算令牌数量(简化实现,实际应使用更准确的方法)
func (s *AIService) CountTokens(text string) int64 {
// 简单估算:每个汉字算2个令牌,每个英文单词算1个令牌
var count int64
for _, r := range text {
if r > 127 {
count += 2
} else {
count++
}
}
return count / 2 // 平均估算
}
+162
View File
@@ -0,0 +1,162 @@
package services
import (
"log"
"time"
"d:\项目\dobaochet\backend\config"
"d:\项目\dobaochet\backend\db"
"d:\项目\dobaochet\backend\models"
)
// QuotaService 配额管理服务
type QuotaService struct {
cfg *config.Config
}
// NewQuotaService 创建配额管理服务实例
func NewQuotaService(cfg *config.Config) *QuotaService {
return &QuotaService{cfg: cfg}
}
// StartQuotaResetJob 启动配额重置定时任务
func (s *QuotaService) StartQuotaResetJob() {
// 立即执行一次重置检查
s.ResetExpiredQuotas()
// 每天检查一次
ticker := time.NewTicker(24 * time.Hour)
go func() {
for range ticker.C {
s.ResetExpiredQuotas()
}
}()
}
// ResetExpiredQuotas 重置过期的配额
func (s *QuotaService) ResetExpiredQuotas() {
log.Println("开始检查并重置过期配额")
// 查询所有需要重置配额的用户
var users []models.User
result := db.GetDB().Preload("Quota").Where("quota.reset_at <= ?", time.Now()).Find(&users)
if result.Error != nil {
log.Printf("查询需要重置配额的用户失败: %v", result.Error)
return
}
if len(users) == 0 {
log.Println("没有需要重置配额的用户")
return
}
log.Printf("发现 %d 个用户需要重置配额", len(users))
// 批量重置配额
tx := db.GetDB().Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
log.Printf("重置配额时发生 panic: %v", r)
}
}()
for _, user := range users {
// 重置配额
user.Quota.UsedTokens = 0
user.Quota.ResetAt = time.Now().Add(s.cfg.Quota.ResetInterval)
if result := tx.Save(&user.Quota); result.Error != nil {
tx.Rollback()
log.Printf("重置用户 %s 配额失败: %v", user.Username, result.Error)
return
}
log.Printf("已重置用户 %s 的配额", user.Username)
}
if err := tx.Commit().Error; err != nil {
log.Printf("提交配额重置事务失败: %v", err)
return
}
log.Printf("成功重置 %d 个用户的配额", len(users))
}
// CheckAndUpdateQuota 检查并更新用户配额
func (s *QuotaService) CheckAndUpdateQuota(userID uint, usedTokens int64) (bool, error) {
// 查询用户配额
var quota models.Quota
result := db.GetDB().Where("user_id = ?", userID).First(&quota)
if result.Error != nil {
return false, result.Error
}
// 检查配额是否过期
if quota.ResetAt.Before(time.Now()) {
// 重置配额
quota.UsedTokens = 0
quota.ResetAt = time.Now().Add(s.cfg.Quota.ResetInterval)
}
// 检查配额是否足够
if quota.UsedTokens+usedTokens > quota.TotalTokens {
return false, nil
}
// 更新配额
quota.UsedTokens += usedTokens
if result := db.GetDB().Save(&quota); result.Error != nil {
return false, result.Error
}
return true, nil
}
// GetUserQuota 获取用户配额信息
func (s *QuotaService) GetUserQuota(userID uint) (*models.Quota, error) {
var quota models.Quota
result := db.GetDB().Where("user_id = ?", userID).First(&quota)
if result.Error != nil {
return nil, result.Error
}
// 检查并重置过期配额
if quota.ResetAt.Before(time.Now()) {
quota.UsedTokens = 0
quota.ResetAt = time.Now().Add(s.cfg.Quota.ResetInterval)
db.GetDB().Save(&quota)
}
return &quota, nil
}
// UpdateUserQuota 更新用户配额
func (s *QuotaService) UpdateUserQuota(userID uint, totalTokens, tokenLimit int64) error {
var quota models.Quota
result := db.GetDB().Where("user_id = ?", userID).First(&quota)
if result.Error != nil {
return result.Error
}
// 更新配额
quota.TotalTokens = totalTokens
quota.TokenLimit = tokenLimit
return db.GetDB().Save(&quota).Error
}
// ResetUserQuota 重置单个用户配额
func (s *QuotaService) ResetUserQuota(userID uint) error {
var quota models.Quota
result := db.GetDB().Where("user_id = ?", userID).First(&quota)
if result.Error != nil {
return result.Error
}
// 重置配额
quota.UsedTokens = 0
quota.ResetAt = time.Now().Add(s.cfg.Quota.ResetInterval)
return db.GetDB().Save(&quota).Error
}
+92
View File
@@ -0,0 +1,92 @@
package utils
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// JWTClaims JWT声明结构体
type JWTClaims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
// GenerateToken 生成JWT令牌
func GenerateToken(userID uint, username string, isAdmin bool, secretKey string, expireTime time.Duration) (string, error) {
// 创建声明
claims := JWTClaims{
UserID: userID,
Username: username,
IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
// 创建令牌
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 签名令牌
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
// ParseToken 解析JWT令牌
func ParseToken(tokenString string, secretKey string) (*JWTClaims, error) {
// 解析令牌
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
// 验证签名方法
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secretKey), nil
})
if err != nil {
return nil, err
}
// 验证令牌并获取声明
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// HashPassword 密码加密
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword 密码验证
func VerifyPassword(hashedPassword, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
}
// GenerateRandomString 生成随机字符串
func GenerateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
randomBytes := make([]byte, length)
for i := range randomBytes {
randomBytes[i] = charset[time.Now().UnixNano()%int64(len(charset))]
}
return string(randomBytes)
}
-34
View File
@@ -1,34 +0,0 @@
# ========== 服务器基本配置 ==========
PORT=3001
HOST=0.0.0.0
SERVER_READ_TIMEOUT=60
SERVER_WRITE_TIMEOUT=600
SERVER_IDLE_TIMEOUT=120
SERVER_GRACEFUL_SHUTDOWN_TIMEOUT=10
# ========== 节点/时区 ==========
IS_SLAVE=false
TZ=Asia/Shanghai
# ========== 认证 ==========
AUTH_KEY=sk-123456
# ========== 数据库 ==========
# 与 compose 里的 mysql 服务一一对应
DATABASE_DSN=root:123456@tcp(mysql:3306)/gpt-load?charset=utf8mb4&parseTime=True&loc=Local
# ========== Redis ==========
# 与 compose 里的 redis 服务对应,使用 0 号库
REDIS_DSN=redis://redis:6379/0
# ========== 并发 / CORS / 日志 ==========
MAX_CONCURRENT_REQUESTS=100
ENABLE_CORS=true
ALLOWED_ORIGINS=*
ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
ALLOWED_HEADERS=*
ALLOW_CREDENTIALS=false
LOG_LEVEL=info
LOG_FORMAT=text
LOG_ENABLE_FILE=true
LOG_FILE_PATH=./data/logs/app.log
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>豆包AI - 智能对话助手</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="sidebar-header">
<h1 class="logo">豆包AI</h1>
<button class="new-chat-btn"><i class="fas fa-plus"></i> 新对话</button>
</div>
<div class="chat-history">
<div class="chat-item active">
<div class="chat-preview">
<span class="chat-title">新对话</span>
<span class="chat-time">刚刚</span>
</div>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content">
<!-- 对话区域 -->
<div class="chat-container">
<div class="chat-header">
<h2>新对话</h2>
<div class="chat-actions">
<button class="action-btn"><i class="fas fa-share-alt"></i></button>
<button class="action-btn"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="messages" id="messages">
<div class="message bot-message">
<div class="message-content">
<div class="avatar bot-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-text">
<p>你好!我是豆包AI,很高兴为你服务。有什么我可以帮助你的吗?</p>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-container">
<div class="input-wrapper">
<textarea
id="user-input"
placeholder="请输入您的问题..."
rows="1"
></textarea>
<div class="input-actions">
<button class="action-btn"><i class="fas fa-paperclip"></i></button>
<button class="action-btn"><i class="fas fa-image"></i></button>
<button id="send-btn" class="send-btn">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="input-footer">
<p class="tip">支持换行输入,按Shift+Enter发送</p>
</div>
</div>
</main>
</div>
<script src="script.js"></script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
// 消息容器
const messagesContainer = document.getElementById('messages');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
// API配置 - 这里将在后续连接到实际的后端API
const API_URL = 'http://localhost:8000/api/chat';
// 添加消息到聊天容器
function addMessage(text, isUser = false) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user-message' : 'bot-message'}`;
const avatarIcon = isUser ? 'fa-user' : 'fa-robot';
const avatarClass = isUser ? 'user-avatar' : 'bot-avatar';
messageDiv.innerHTML = `
<div class="message-content">
<div class="avatar ${avatarClass}">
<i class="fas ${avatarIcon}"></i>
</div>
<div class="message-text">
<p>${text}</p>
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
// 滚动到最新消息
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 发送消息到API
async function sendMessage() {
const text = userInput.value.trim();
if (!text) return;
// 添加用户消息
addMessage(text, true);
// 清空输入框
userInput.value = '';
userInput.style.height = 'auto';
// 添加加载状态
const loadingDiv = document.createElement('div');
loadingDiv.className = 'message bot-message';
loadingDiv.innerHTML = `
<div class="message-content">
<div class="avatar bot-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-text">
<div class="loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</div>
</div>
</div>
`;
messagesContainer.appendChild(loadingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
try {
// 调用API获取回复
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: text
})
});
if (!response.ok) {
throw new Error('API请求失败');
}
const data = await response.json();
// 移除加载状态
loadingDiv.remove();
// 添加机器人回复
addMessage(data.response);
} catch (error) {
// 移除加载状态
loadingDiv.remove();
// 添加错误消息
addMessage('抱歉,暂时无法为您提供服务,请稍后重试。');
console.error('发送消息失败:', error);
}
}
// 处理输入框高度自适应
function adjustTextareaHeight() {
userInput.style.height = 'auto';
userInput.style.height = Math.min(userInput.scrollHeight, 120) + 'px';
}
// 事件监听
userInput.addEventListener('input', adjustTextareaHeight);
// 回车键发送消息,Shift+Enter换行
userInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 发送按钮点击事件
sendBtn.addEventListener('click', sendMessage);
// 初始化 - 添加一些CSS样式到head
const style = document.createElement('style');
style.textContent = `
/* 加载动画 */
.loading {
display: flex;
gap: 5px;
}
.loading-dot {
width: 8px;
height: 8px;
background-color: #1a73e8;
border-radius: 50%;
animation: loading 1.4s infinite ease-in-out both;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loading {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
`;
document.head.appendChild(style);
// 初始欢迎消息已经在HTML中定义,这里可以添加一些示例对话
// addMessage('你好!我是豆包AI,很高兴为你服务。有什么我可以帮助你的吗?');
+329
View File
@@ -0,0 +1,329 @@
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f7;
color: #333;
}
/* 容器布局 */
.container {
display: flex;
height: 100vh;
overflow: hidden;
}
/* 侧边栏样式 */
.sidebar {
width: 300px;
background-color: #ffffff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #1a73e8;
margin-bottom: 20px;
}
.new-chat-btn {
width: 100%;
padding: 12px;
background-color: #f0f0f0;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.new-chat-btn:hover {
background-color: #e8f0fe;
border-color: #1a73e8;
color: #1a73e8;
}
/* 聊天历史 */
.chat-history {
flex: 1;
overflow-y: auto;
padding: 10px 0;
}
.chat-item {
padding: 12px 20px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 0 20px 20px 0;
margin-right: 10px;
}
.chat-item:hover {
background-color: #f8f9fa;
}
.chat-item.active {
background-color: #e8f0fe;
color: #1a73e8;
}
.chat-preview {
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.chat-time {
font-size: 12px;
color: #666;
}
/* 主内容区 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 聊天容器 */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: #ffffff;
}
.chat-header {
padding: 20px 30px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h2 {
font-size: 18px;
font-weight: 500;
color: #333;
}
.chat-actions {
display: flex;
gap: 10px;
}
.action-btn {
background: none;
border: none;
font-size: 16px;
color: #666;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: all 0.2s ease;
}
.action-btn:hover {
background-color: #f0f0f0;
color: #333;
}
/* 消息区域 */
.messages {
flex: 1;
overflow-y: auto;
padding: 20px 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* 消息样式 */
.message {
display: flex;
flex-direction: column;
gap: 10px;
}
.message-content {
display: flex;
gap: 15px;
align-items: flex-start;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.bot-avatar {
background-color: #e8f0fe;
color: #1a73e8;
}
.user-avatar {
background-color: #f0f0f0;
color: #666;
}
.message-text {
flex: 1;
max-width: 70%;
}
.message-text p {
line-height: 1.6;
font-size: 15px;
color: #333;
}
/* 输入区域 */
.input-container {
padding: 20px 30px;
background-color: #f5f5f7;
border-top: 1px solid #e0e0e0;
}
.input-wrapper {
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 24px;
padding: 10px 15px;
display: flex;
align-items: flex-end;
gap: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
#user-input {
flex: 1;
border: none;
outline: none;
resize: none;
font-size: 15px;
line-height: 1.6;
padding: 10px 0;
font-family: inherit;
max-height: 120px;
overflow-y: auto;
}
#user-input::placeholder {
color: #999;
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
.send-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #1a73e8;
border: none;
color: #ffffff;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.send-btn:hover {
background-color: #1557b0;
transform: scale(1.05);
}
.send-btn:active {
transform: scale(0.95);
}
.input-footer {
margin-top: 10px;
text-align: center;
}
.tip {
font-size: 12px;
color: #999;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
width: 250px;
}
.message-text {
max-width: 85%;
}
.chat-container {
padding: 15px;
}
.input-container {
padding: 15px;
}
}