diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..22ef028 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,36 @@ +# ===== 服务器配置 +SERVER_PORT=8080 +SERVER_MODE=debug +SERVER_READ_TIMEOUT=30 +SERVER_WRITE_TIMEOUT=30 + +# ===== 数据库配置(MySQL) +DB_DRIVER=mysql +DB_HOST=124.221.239.98 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=machine03 +DB_NAME=openclaw +DB_SSL_MODE=TRUE + +# ===== Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# ===== JWT 配置 +JWT_SECRET=change-this-to-a-secure-random-string +JWT_EXPIRATION=86400 + +# ===== AI Provider 配置 +AI_PROVIDER=openai +AI_API_KEY=your-api-key-here +AI_MODEL=gpt-3.5-turbo +AI_BASE_URL=https://api.openai.com/v1 +AI_MAX_TOKENS=1000 +AI_TEMPERATURE=0.7 + +# ===== WebSocket 配置 +WS_PORT=8081 +WS_PATH=/ws diff --git a/backend/config/config.go b/backend/config/config.go index 56f085d..3bbe64e 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -28,6 +28,7 @@ type DatabaseConfig struct { Password string DBName string SSLMode string + Driver string // mysql, postgres } type RedisConfig struct { @@ -43,11 +44,11 @@ type JWTConfig struct { } type AIConfig struct { - Provider string - APIKey string - Model string - BaseURL string - MaxTokens int + Provider string + APIKey string + Model string + BaseURL string + MaxTokens int Temperature float64 } @@ -56,6 +57,7 @@ type WebSocketConfig struct { Path string } +// Load 加载配置文件 func Load() (*Config, error) { return &Config{ Server: ServerConfig{ @@ -65,12 +67,13 @@ func Load() (*Config, error) { WriteTimeout: getEnvAsInt("SERVER_WRITE_TIMEOUT", 30), }, Database: DatabaseConfig{ - Host: getEnv("DB_HOST", "localhost"), - Port: getEnvAsInt("DB_PORT", 5432), - User: getEnv("DB_USER", "postgres"), - Password: getEnv("DB_PASSWORD", "postgres"), - DBName: getEnv("DB_NAME", "customer_service"), - SSLMode: getEnv("DB_SSL_MODE", "disable"), + Host: getEnv("DB_HOST", "124.221.239.98"), + Port: getEnvAsInt("DB_PORT", 3306), + User: getEnv("DB_USER", "root"), + Password: getEnv("DB_PASSWORD", "machine03"), + DBName: getEnv("DB_NAME", "openclaw"), + SSLMode: getEnv("DB_SSL_MODE", "TRUE"), + Driver: getEnv("DB_DRIVER", "mysql"), }, Redis: RedisConfig{ Host: getEnv("REDIS_HOST", "localhost"), @@ -83,11 +86,11 @@ func Load() (*Config, error) { Expiration: getEnvAsInt("JWT_EXPIRATION", 86400), }, AI: AIConfig{ - Provider: getEnv("AI_PROVIDER", "openai"), - APIKey: getEnv("AI_API_KEY", ""), - Model: getEnv("AI_MODEL", "gpt-3.5-turbo"), - BaseURL: getEnv("AI_BASE_URL", "https://api.openai.com/v1"), - MaxTokens: getEnvAsInt("AI_MAX_TOKENS", 1000), + Provider: getEnv("AI_PROVIDER", "openai"), + APIKey: getEnv("AI_API_KEY", ""), + Model: getEnv("AI_MODEL", "gpt-3.5-turbo"), + BaseURL: getEnv("AI_BASE_URL", "https://api.openai.com/v1"), + MaxTokens: getEnvAsInt("AI_MAX_TOKENS", 1000), Temperature: getEnvAsFloat("AI_TEMPERATURE", 0.7), }, WebSocket: WebSocketConfig{ @@ -97,15 +100,26 @@ func Load() (*Config, error) { }, nil } +// GetDSN 返回数据库连接字符串 +func (c *Config) GetDSN() string { + dbConfig := c.Database + if dbConfig.Driver == "mysql" { + return dbConfig.User + ":" + dbConfig.Password + "@tcp(" + + dbConfig.Host + ":" + strconv.Itoa(dbConfig.Port) + ")/" + + dbConfig.DBName + "?charset=utf8mb4&parseTime=True&loc=Local" + } + return "" // PostgreSQL 暂未实现 +} + func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { + if value, exists := os.LookupEnv(key); exists { return value } return defaultValue } func getEnvAsInt(key string, defaultValue int) int { - if value := os.Getenv(key); value != "" { + if value, exists := os.LookupEnv(key); exists { if intValue, err := strconv.Atoi(value); err == nil { return intValue } @@ -114,7 +128,7 @@ func getEnvAsInt(key string, defaultValue int) int { } func getEnvAsFloat(key string, defaultValue float64) float64 { - if value := os.Getenv(key); value != "" { + if value, exists := os.LookupEnv(key); exists { if floatValue, err := strconv.ParseFloat(value, 64); err == nil { return floatValue } diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..a2d8d61 --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,82 @@ +package database + +import ( + "fmt" + "log" + "smart-customer-service/config" + "smart-customer-service/internal/models" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +// Init 初始化数据库连接 +func Init(cfg *config.Config) error { + dsn := cfg.GetDSN() + + // 设置日志级别 + var logLevel logger.LogLevel + if cfg.Server.Mode == "debug" { + logLevel = logger.Info + } else { + logLevel = logger.Warn + } + + var err error + // 连接 MySQL 数据库 + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logLevel), + DisableForeignKeyConstraintWhenMigrating: true, + }) + + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + log.Println("✅ Database connection established") + + // 自动迁移模型 + if err := Migrate(cfg); err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + + log.Println("✅ Database migration completed") + return nil +} + +// Migrate 执行数据库迁移 +func Migrate(cfg *config.Config) error { + // 全局模型 + if err := DB.AutoMigrate( + // 租户体系 + &models.Tenant{}, + &models.User{}, + &models.Role{}, + &models.Resource{}, + &models.RoleResource{}, + &models.UserRole{}, + + // 业务模型 + &models.Conversation{}, + &models.Message{}, + &models.Ticket{}, + &models.KnowledgeBase{}, + &models.KnowledgeItem{}, + ); err != nil { + return err + } + + return nil +} + +// Close 关闭数据库连接 +func Close() { + if DB != nil { + db, _ := DB.DB() + db.Close() + log.Println("✅ Database connection closed") + } +} diff --git a/backend/internal/handlers/knowledge.go b/backend/internal/handlers/knowledge.go new file mode 100644 index 0000000..02da817 --- /dev/null +++ b/backend/internal/handlers/knowledge.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "smart-customer-service/config" + "smart-customer-service/internal/database" + "smart-customer-service/internal/models" + "strconv" +) + +type KnowledgeHandler struct { + cfg *config.Config +} + +// 知识库列表 +func (h *KnowledgeHandler) ListKnowledgeBases(c *gin.Context) { + tenantID, _ := c.Get("tenant_id").(uint) + pagesize := c.DefaultQuery("page_size", "10") + page := c.DefaultQuery("page", "1") + + limit, _ := strconv.Atoi(pagesize) + offset, _ := strconv.Atoi(page) + + var bases []models.KnowledgeBase + var total int64 + + db := database.DB.Where("tenant_id = ?", tenantID) + db.Count(\&total) + db.Order("sort_order ASC, created_at DESC").Limit(limit).Offset((offset-1)*limit).Find(\&bases) + + c.JSON(200, gin.H{ + "total": total, + "page": page, + "page_size": limit, + "knowledge_bases": bases, + }) +} + +// 创建知识库 +func (h *KnowledgeHandler) CreateKnowledgeBase(c *gin.Context) { + tenantID, _ := c.Get("tenant_id").(uint) + + var base models.KnowledgeBase + if err := c.ShouldBindJSON(\&base); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + base.TenantID = tenantID + if base.Status == "" { + base.Status = models.KBStatusDraft + } + + if err := database.DB.Create(\&base).Error; err != nil { + c.JSON(500, gin.H{"error": "创建失败", "message": err.Error()}) + return + } + + c.JSON(201, gin.H{ + "message": "知识库创建成功", + "base_id": base.ID, + }) +} + +// 更新知识库 +func (h *KnowledgeHandler) UpdateKnowledgeBase(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + var base models.KnowledgeBase + if err := database.DB.First(\&base, uint(id)).Error; err != nil { + c.JSON(404, gin.H{"error": "知识库不存在"}) + return + } + + if err := c.ShouldBindJSON(\&base); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + if err := database.DB.Save(\&base).Error; err != nil { + c.JSON(500, gin.H{"error": "更新失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "知识库更新成功"}) +} + +// 删除知识库 +func (h *KnowledgeHandler) DeleteKnowledgeBase(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + if err := database.DB.Delete(\&models.KnowledgeBase{}, uint(id)).Error; err != nil { + c.JSON(500, gin.H{"error": "删除失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "知识库删除成功"}) +} + +// 知识条目列表 +func (h *KnowledgeHandler) ListKnowledgeItems(c *gin.Context) { + tenantID, _ := c.Get("tenant_id").(uint) + status := c.Query("status") + pagesize := c.DefaultQuery("page_size", "10") + page := c.DefaultQuery("page", "1") + + limit, _ := strconv.Atoi(pagesize) + offset, _ := strconv.Atoi(page) + + var items []models.KnowledgeItem + var total int64 + + db := database.DB.Where("tenant_id = ?", tenantID) + if status != "" { + db = db.Where("status = ?", status) + } + + db.Count(\&total) + db.Preload("KnowledgeBase").Order("updated_at DESC").Limit(limit).Offset((offset-1)*limit).Find(\&items) + + c.JSON(200, gin.H{ + "total": total, + "page": page, + "page_size": limit, + "items": items, + }) +} + +// 创建知识条目 +func (h *KnowledgeHandler) CreateKnowledgeItem(c *gin.Context) { + tenantID, _ := c.Get("tenant_id").(uint) + knowledgeBaseID := c.PostForm("knowledge_base_id") + + var item models.KnowledgeItem + if err := c.ShouldBindJSON(\&item); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + item.TenantID = tenantID + if knowledgeBaseID != "" { + kbID, _ := strconv.ParseUint(knowledgeBaseID, 10, 32) + item.KnowledgeBaseID = uint(kbID) + } + if item.Status == "" { + item.Status = models.KnowledgeStatusDraft + } + + if err := database.DB.Create(\&item).Error; err != nil { + c.JSON(500, gin.H{"error": "创建失败", "message": err.Error()}) + return + } + + c.JSON(201, gin.H{ + "message": "知识条目创建成功", + "item_id": item.ID, + }) +} + +// 更新知识条目 +func (h *KnowledgeHandler) UpdateKnowledgeItem(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + var item models.KnowledgeItem + if err := database.DB.First(\&item, uint(id)).Error; err != nil { + c.JSON(404, gin.H{"error": "知识条目不存在"}) + return + } + + if err := c.ShouldBindJSON(\&item); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + if err := database.DB.Save(\&item).Error; err != nil { + c.JSON(500, gin.H{"error": "更新失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "知识条目更新成功"}) +} + +// 删除知识条目 +func (h *KnowledgeHandler) DeleteKnowledgeItem(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + if err := database.DB.Delete(\&models.KnowledgeItem{}, uint(id)).Error; err != nil { + c.JSON(500, gin.H{"error": "删除失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "知识条目删除成功"}) +} + +// 搜索知识条目 +func (h *KnowledgeHandler) Search(c *gin.Context) { + tenantID, _ := c.Get("tenant_id").(uint) + keyword := c.Query("keyword") + category := c.Query("category") + + var items []models.KnowledgeItem + db := database.DB.Where("tenant_id = ? AND (title LIKE ? OR content LIKE ?)", tenantID, "%"+keyword+"%", "%"+keyword+"%") + if category != "" { + db = db.Where("category = ?", category) + } + + db.Preload("KnowledgeBase").Limit(20).Find(\&items) + + c.JSON(200, gin.H{ + "keyword": keyword, + "items": items, + "total": len(items), + }) +} + +// 统计知识库数据量 +func (h *KnowledgeHandler) GetStats(c *gin.Context) { + tenantID, _ := c.Get("tenant_id").(uint) + + var totalItems int64 + var publishedItems int64 + var draftItems int64 + + database.DB.Model(\&models.KnowledgeItem{}).Where("tenant_id = ?", tenantID).Count(\&totalItems) + database.DB.Model(\&models.KnowledgeItem{}).Where("tenant_id = ? AND status = ?", tenantID, models.KnowledgeStatusPublished).Count(\&publishedItems) + database.DB.Model(\&models.KnowledgeItem{}).Where("tenant_id = ? AND status = ?", tenantID, models.KnowledgeStatusDraft).Count(\&draftItems) + + c.JSON(200, gin.H{ + "total_items": totalItems, + "published": publishedItems, + "draft": draftItems, + }) +} diff --git a/backend/internal/handlers/resource.go b/backend/internal/handlers/resource.go new file mode 100644 index 0000000..bd8f5f4 --- /dev/null +++ b/backend/internal/handlers/resource.go @@ -0,0 +1,284 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "smart-customer-service/internal/models" +) + +// ResourceHandler 资源处理器 +type ResourceHandler struct{} + +// Create 创建资源 +func (h *ResourceHandler) Create(w http.ResponseWriter, r *http.Request) { + var resource models.Resource + if err := json.NewDecoder(r.Body).Decode(&resource); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 验证必填字段 + if resource.Name == "" || resource.Code == "" || resource.Type == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "name, code, and type are required", + }) + return + } + + // 验证资源类型 + validTypes := map[string]bool{ + "api": true, // API 端点 + "page": true, // 页面 + "button": true, // 按钮 + "data": true, // 数据字段 + } + + if !validTypes[resource.Type] { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid resource type, must be one of: api, page, button, data", + }) + return + } + + // 检查代码是否已存在 + // if exists := checkResourceExists(resource.Code); exists { + // http.Error(w, `{"error": "resource code already exists"}`, http.StatusConflict) + // return + // } + + // TODO: 保存到数据库 + // db.Create(&resource) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "资源创建成功", + "data": resource, + }) +} + +// List 获取资源列表 +func (h *ResourceHandler) List(w http.ResponseWriter, r *http.Request) { + page := getPageParam(r, 1) + perPage := getPageParam(r, 20) + + // 获取过滤参数 + tenantIDStr := r.URL.Query().Get("tenant_id") + resourceType := r.URL.Query().Get("type") + group := r.URL.Query().Get("group") + isSystem := r.URL.Query().Get("is_system") + + // TODO: 查询数据库 + // var resources []models.Resource + // query := db.Where("tenant_id = ?", tenantID) + // if resourceType != "" { + // query = query.Where("type = ?", resourceType) + // } + // if group != "" { + // query = query.Where("group = ?", group) + // } + // if isSystem == "true" { + // query = query.Where("is_system = ?", true) + // } + // query.Order("sort_order").Find(&resources) + + var resources []models.Resource + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "total": 0, + "page": page, + "per_page": perPage, + "total_pages": 0, + "filters": map[string]string{ + "type": resourceType, + "group": group, + "is_system": isSystem, + }, + "data": resources, + }) +} + +// Get 获取单个资源 +func (h *ResourceHandler) Get(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 查询数据库 (包含关联数据) + // var resource models.Resource + // db.Preload("Roles").Preload("Parent").Preload("Children").First(&resource, id) + + var resource models.Resource + + if resource.ID == 0 { + http.Error(w, `{"error": "resource not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resource) +} + +// Update 更新资源 +func (h *ResourceHandler) Update(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + var resource models.Resource + if err := json.NewDecoder(r.Body).Decode(&resource); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 不允许修改代码 (Code 是唯一索引) + // resource.Code = "" + + // TODO: 更新数据库 + // db.Model(&resource).Where("id = ?", id).Updates(resource) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "资源更新成功", + "data": resource, + }) +} + +// Delete 删除资源 +func (h *ResourceHandler) Delete(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // 检查是否有子资源 + // childCount := countChildResources(id) + // if childCount > 0 { + // http.Error(w, `{"error": "cannot delete resource with children"}`, http.StatusBadRequest) + // return + // } + + // 检查是否有角色使用 + // roleCount := countRolesWithResource(id) + // if roleCount > 0 { + // http.Error(w, `{"error": "cannot delete resource with associated roles"}`, http.StatusBadRequest) + // return + // } + + // TODO: 软删除 + // db.Where("id = ?", id).Update("deleted_at", time.Now()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "资源删除成功", + "id": id, + }) +} + +// GetTree 获取资源树形结构 +func (h *ResourceHandler) GetTree(w http.ResponseWriter, r *http.Request) { + // 获取过滤参数 + tenantIDStr := r.URL.Query().Get("tenant_id") + groupBy := r.URL.Query().Get("group_by") // 按分组、类型等分组 + + // TODO: 查询数据库 + // 1. 查询所有资源 + // 2. 构建树形结构 + // 3. 按分组分类返回 + + type ResourceTree struct { + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Type string `json:"type"` + Children []ResourceTree `json:"children,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` + } + + var tree []ResourceTree + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "tree": tree, + }) +} + +// GetResourceByCode 通过代码获取资源 +func (h *ResourceHandler) GetResourceByCode(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, `{"error": "code is required"}`, http.StatusBadRequest) + return + } + + // TODO: 查询数据库 + // var resource models.Resource + // db.Where("code = ?", code).First(&resource) + + var resource models.Resource + + if resource.ID == 0 { + http.Error(w, `{"error": "resource not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resource) +} + +// CheckPermission 检查权限是否有效 +func (h *ResourceHandler) CheckPermission(w http.ResponseWriter, r *http.Request) { + type PermissionCheck struct { + UserID uint `json:"user_id"` + TenantID uint `json:"tenant_id"` + ResourceCode string `json:"resource_code"` + Action string `json:"action"` // create, read, update, delete, etc. + } + + var req PermissionCheck + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO: 实现权限检查逻辑 + // 1. 查询用户角色 + // 2. 查询角色资源 + // 3. 检查资源是否包含该操作 + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "has_permission": false, + "reason": "permission check not implemented", + }) +} diff --git a/backend/internal/handlers/role.go b/backend/internal/handlers/role.go new file mode 100644 index 0000000..125802b --- /dev/null +++ b/backend/internal/handlers/role.go @@ -0,0 +1,240 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "smart-customer-service/internal/models" +) + +// RoleHandler 角色处理器 +type RoleHandler struct{} + +// Create 创建角色 +func (h *RoleHandler) Create(w http.ResponseWriter, r *http.Request) { + var role models.Role + if err := json.NewDecoder(r.Body).Decode(&role); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 验证必填字段 + if role.Name == "" || role.Code == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "name and code are required", + }) + return + } + + // 检查角色代码是否已存在 + // if exists := checkRoleExists(role.Code); exists { + // http.Error(w, `{"error": "role code already exists"}`, http.StatusConflict) + // return + // } + + // TODO: 保存到数据库 + // db.Create(&role) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "角色创建成功", + "data": role, + }) +} + +// List 获取角色列表 +func (h *RoleHandler) List(w http.ResponseWriter, r *http.Request) { + page := getPageParam(r, 1) + perPage := getPageParam(r, 20) + + // 获取过滤参数 + tenantIDStr := r.URL.Query().Get("tenant_id") + status := r.URL.Query().Get("status") + isGlobal := r.URL.Query().Get("is_global") + + // TODO: 查询数据库 + // var roles []models.Role + // query := db.Where("tenant_id = ?", tenantID) + // if status != "" { + // query = query.Where("status = ?", status) + // } + // if isGlobal == "true" { + // query = query.Where("is_global = ?", true) + // } + // query.Preload("Users").Find(&roles) + + var roles []models.Role + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "total": 0, + "page": page, + "per_page": perPage, + "total_pages": 0, + "data": roles, + }) +} + +// Get 获取单个角色 +func (h *RoleHandler) Get(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 查询数据库 (包含关联数据) + // var role models.Role + // db.Preload("Resources").Preload("Users").First(&role, id) + + var role models.Role + + if role.ID == 0 { + http.Error(w, `{"error": "role not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(role) +} + +// Update 更新角色 +func (h *RoleHandler) Update(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + var role models.Role + if err := json.NewDecoder(r.Body).Decode(&role); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO: 更新数据库 + // db.Model(&role).Where("id = ?", id).Updates(role) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "角色更新成功", + "data": role, + }) +} + +// Delete 删除角色 +func (h *RoleHandler) Delete(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // 检查是否有用户使用该角色 + // userCount := countUsersWithRole(id) + // if userCount > 0 { + // http.Error(w, `{"error": "cannot delete role with associated users"}`, http.StatusBadRequest) + // return + // } + + // TODO: 软删除 + // db.Where("id = ?", id).Update("deleted_at", time.Now()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "角色删除成功", + "id": id, + }) +} + +// AssignResources 分配资源给角色 +func (h *RoleHandler) AssignResources(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + type resourceAssignment struct { + ResourceIDs []uint `json:"resource_ids"` + ResourceCode []string `json:"resource_codes"` // 也可以通过代码分配 + } + + var req resourceAssignment + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO: 分配资源 + // 1. 验证资源是否存在 + // 2. 更新 role_resources 关联表 + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "资源分配成功", + "data": req, + }) +} + +// GetPermissions 获取角色的权限列表 +func (h *RoleHandler) GetPermissions(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 查询数据库 + // 1. 查询角色 + // 2. 查询角色关联的资源 + // 3. 提取所有资源的操作权限 + + type PermissionResult struct { + ResourceCode string `json:"resource_code"` + Actions []string `json:"actions"` + Description string `json:"description"` + } + + var permissions []PermissionResult + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "role_id": id, + "permissions": permissions, + }) +} diff --git a/backend/internal/handlers/tenant.go b/backend/internal/handlers/tenant.go new file mode 100644 index 0000000..fa8f5bb --- /dev/null +++ b/backend/internal/handlers/tenant.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "net/http" + "smart-customer-service/internal/models" + "database/sql" + "encoding/json" + "fmt" + "strconv" +) + +// TenantHandler 租户处理器 +type TenantHandler struct { + // 这里可以添加 database 连接 +} + +// Create 创建租户 +func (h *TenantHandler) Create(w http.ResponseWriter, r *http.Request) { + var tenant models.Tenant + if err := json.NewDecoder(r.Body).Decode(&tenant); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 验证必填字段 + if tenant.Name == "" || tenant.Email == "" { + http.Error(w, `{"error": "name and email are required"}`, http.StatusBadRequest) + return + } + + // TODO: 保存到数据库 + // db.Create(&tenant) + + // 返回响应 + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "租户创建成功", + "data": tenant, + }) +} + +// List 获取租户列表 +func (h *TenantHandler) List(w http.ResponseWriter, r *http.Request) { + // 获取分页参数 + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page == 0 { + page = 1 + } + perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + if perPage == 0 { + perPage = 20 + } + + // TODO: 查询数据库 + // var tenants []models.Tenant + // db.Offset((page-1)*perPage).Limit(perPage).Find(&tenants) + + var tenants []models.Tenant + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "total": 0, + "page": page, + "per_page": perPage, + "total_pages": 0, + "data": tenants, + }) +} + +// Get 获取单个租户 +func (h *TenantHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.URL.Query().Get("id"), 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 查询数据库 + // var tenant models.Tenant + // db.First(&tenant, id) + + var tenant models.Tenant + + if tenant.ID == 0 { + http.Error(w, `{"error": "tenant not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tenant) +} + +// Update 更新租户 +func (h *TenantHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.URL.Query().Get("id"), 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + var tenant models.Tenant + if err := json.NewDecoder(r.Body).Decode(&tenant); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO: 更新数据库 + // db.Model(&tenant).Where("id = ?", id).Updates(map[string]interface{}{ + // "name": tenant.Name, + // "email": tenant.Email, + // }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "租户更新成功", + "data": tenant, + }) +} + +// Delete 删除租户 +func (h *TenantHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.URL.Query().Get("id"), 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 软删除 + // db.Where("id = ?", id).Update("deleted_at", time.Now()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": fmt.Sprintf("租户 %d 删除成功", id), + }) +} diff --git a/backend/internal/handlers/ticket.go b/backend/internal/handlers/ticket.go new file mode 100644 index 0000000..aaccf01 --- /dev/null +++ b/backend/internal/handlers/ticket.go @@ -0,0 +1,204 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "smart-customer-service/config" + "smart-customer-service/internal/database" + "smart-customer-service/internal/models" + "smart-customer-service/internal/middleware" + "strconv" + "time" +) + +type TicketHandler struct { + cfg *config.Config +} + +func (h *TicketHandler) List(c *gin.Context) { + tenantID, _ := c.Get("tenant_id") + db := database.DB + + // 查询参数 + status := c.Query("status") + priority := c.Query("priority") + category := c.Query("category") + pagesize := c.DefaultQuery("page_size", "10") + page := c.DefaultQuery("page", "1") + + limit, _ := strconv.Atoi(pagesize) + offset, _ := strconv.Atoi(page) + q := db.Where("tenant_id = ?", tenantID) + + if status != "" { + q = q.Where("status = ?", status) + } + if priority != "" { + q = q.Where("priority = ?", priority) + } + if category != "" { + q = q.Where("category = ?", category) + } + + var tickets []models.Ticket + var total int64 + + q.Count(&total) + q.Preload("User").Order("priority DESC, created_at DESC").Limit(limit).Offset((offset-1)*limit).Find(\&tickets) + + c.JSON(200, gin.H{ + "total": total, + "page": page, + "page_size": limit, + "tickets": tickets, + }) +} + +func (h *TicketHandler) Create(c *gin.Context) { + userID, _ := c.Get("user_id").(uint) + tenantID, _ := c.Get("tenant_id").(uint) + + var ticket models.Ticket + if err := c.ShouldBindJSON(\&ticket); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + // 生成工单编号 + now := time.Now() + ticket.TicketNumber = h.generateTicketNumber(now) + ticket.UserID = \&userID + ticket.TenantID = tenantID + if ticket.Priority == "" { + ticket.Priority = models.TicketPriorityMedium + } + if ticket.Status == "" { + ticket.Status = models.TicketStatusOpen + } + if ticket.Category == nil { + category := models.TicketCategorySupport + ticket.Category = \&category + } + + if err := database.DB.Create(\&ticket).Error; err != nil { + c.JSON(500, gin.H{"error": "创建失败", "message": err.Error()}) + return + } + + c.JSON(201, gin.H{ + "message": "工单创建成功", + "ticket_id": ticket.ID, + "ticket_number": ticket.TicketNumber, + }) +} + +func (h *TicketHandler) Get(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + var ticket models.Ticket + if err := database.DB.Preload("User").Preload("Assignee").First(\&ticket, uint(id)); err != nil { + c.JSON(404, gin.H{"error": "工单不存在"}) + return + } + + c.JSON(200, ticket) +} + +func (h *TicketHandler) Update(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + var ticket models.Ticket + if err := database.DB.First(\&ticket, uint(id)).Error; err != nil { + c.JSON(404, gin.H{"error": "工单不存在"}) + return + } + + if err := c.ShouldBindJSON(\&ticket); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + if err := database.DB.Save(\&ticket).Error; err != nil { + c.JSON(500, gin.H{"error": "更新失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "工单更新成功"}) +} + +func (h *TicketHandler) Delete(c *gin.Context) { + idStr := c.Param("id") + id, _ := strconv.ParseUint(idStr, 10, 32) + + if err := database.DB.Delete(\&models.Ticket{}, uint(id)).Error; err != nil { + c.JSON(500, gin.H{"error": "删除失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "工单删除成功"}) +} + +func (h *TicketHandler) Assign(c *gin.Context) { + idStr := c.Param("id"), + id, _ := strconv.ParseUint(idStr, 10, 32) + + var ticket models.Ticket + if err := database.DB.First(\&ticket, uint(id)).Error; err != nil { + c.JSON(404, gin.H{"error": "工单不存在"}) + return + } + + var assignRequest struct { + AssignedTo uint `json:"assigned_to"` + } + if err := c.ShouldBindJSON(\&assignRequest); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + ticket.AssignedTo = \&assignRequest.AssignedTo + ticket.Status = models.TicketStatusInProgress + + if err := database.DB.Save(\&ticket).Error; err != nil { + c.JSON(500, gin.H{"error": "指配失败", "message": err.Error()}) + return + } + + c.JSON(200, gin.H{"message": "工单指配成功"}) +} + +func (h *TicketHandler) AddComment(c *gin.Context) { + idStr := c.Param("id"), + id, _ := strconv.ParseUint(idStr, 10, 32) + + userID, _ := c.Get("user_id").(uint) + + var comment struct { + Content string `json:"content"` + IsInternal bool `json:"is_internal" binding:"required"` + } + if err := c.ShouldBindJSON(\&comment); err != nil { + c.JSON(400, gin.H{"error": "参数错误", "message": err.Error()}) + return + } + + msg := models.TicketMessage{ + TicketID: uint(id), + UserID: userID, + Content: comment.Content, + IsInternal: comment.IsInternal, + } + + if err := database.DB.Create(\&msg).Error; err != nil { + c.JSON(500, gin.H{"error": "添加备注失败", "message": err.Error()}) + return + } + + c.JSON(201, gin.H{"message": "备注添加成功", "comment_id": msg.ID}) +} + +// generateTicketNumber 生成工单编号 +func (h *TicketHandler) generateTicketNumber(now time.Time) string { + return now.Format("20060102") + "-" + "TKT" + "00001" +} diff --git a/backend/internal/handlers/user.go b/backend/internal/handlers/user.go new file mode 100644 index 0000000..c77168a --- /dev/null +++ b/backend/internal/handlers/user.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "smart-customer-service/internal/models" +) + +// UserHandler 用户处理器 +type UserHandler struct{} + +// Create 创建用户 +func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) { + var user models.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 验证必填字段 + if user.Username == "" || user.Email == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "username and email are required", + }) + return + } + + // 密码不能为空 + if user.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "password is required", + }) + return + } + + // TODO: 密码加密、保存到数据库 + // user.Password = bcrypt.GenerateFromPassword(password) + // db.Create(&user) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "用户创建成功", + "data": user, + }) +} + +// List 获取用户列表 +func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) { + // 获取分页参数 + page := getPageParam(r, 1) + perPage := getPageParam(r, 20) + + // 获取过滤参数 + tenantIDStr := r.URL.Query().Get("tenant_id") + status := r.URL.Query().Get("status") + keyword := r.URL.Query().Get("keyword") + + // TODO: 查询数据库 + // var users []models.User + // query := db.Where("tenant_id = ?", tenantID) + // if status != "" { + // query = query.Where("status = ?", status) + // } + // if keyword != "" { + // query = query.Where("username LIKE ? OR email LIKE ? OR full_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + // } + // query.Count(&total).Offset((page-1)*perPage).Limit(perPage).Preload("Tenant").Find(&users) + + var users []models.User + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "total": 0, + "page": page, + "per_page": perPage, + "total_pages": 0, + "filters": map[string]string{ + "tenant_id": tenantIDStr, + "status": status, + "keyword": keyword, + }, + "data": users, + }) +} + +// Get 获取单个用户 +func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 查询数据库 (包含关联数据) + // var user models.User + // db.Preload("Tenant").Preload("Roles").First(&user, id) + + var user models.User + + if user.ID == 0 { + http.Error(w, `{"error": "user not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} + +// Update 更新用户 +func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + var user models.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 不允许修改用户名和租户 ID + user.Username = "" + user.TenantID = 0 + + // TODO: 更新数据库 + // db.Model(&user).Where("id = ?", id).Updates(user) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "用户更新成功", + "data": user, + }) +} + +// Delete 删除用户 +func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, `{"error": "invalid id"}`, http.StatusBadRequest) + return + } + + // TODO: 软删除 + // db.Where("id = ?", id).Update("deleted_at", time.Now()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "用户删除成功", + "id": id, + }) +} + +// ChangePassword 修改密码 +func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + type passwordChange struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + ConfirmPassword string `json:"confirm_password"` + } + + var req passwordChange + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // 验证新密码 + if req.NewPassword != req.ConfirmPassword { + http.Error(w, `{"error": "new passwords do not match"}`, http.StatusBadRequest) + return + } + + // 密码最小长度验证 + if len(req.NewPassword) < 8 { + http.Error(w, `{"error": "password must be at least 8 characters"}`, http.StatusBadRequest) + return + } + + // TODO: 验证旧密码、更新新密码 + // 1. 查询用户旧密码 + // 2. bcrypt.CompareHashAndPassword + // 3. 更新密码 + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "密码修改成功", + }) +} + +// AssignRoles 分配角色 +func (h *UserHandler) AssignRoles(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error": "id is required"}`, http.StatusBadRequest) + return + } + + type roleAssignment struct { + RoleIDs []uint `json:"role_ids"` + } + + var req roleAssignment + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO: 分配角色 + // 1. 查询角色是否存在 + // 2. 更新 user_roles 关联表 + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "角色分配成功", + "data": req, + }) +} + +// 辅助函数:获取分页参数 +func getPageParam(r *http.Request, defaultVal int) int { + strVal := r.URL.Query().Get("page") + if strVal == "" { + return defaultVal + } + val, err := strconv.Atoi(strVal) + if err != nil || val < 1 { + return defaultVal + } + return val +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..e3d6d66 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,203 @@ +package middleware + +import ( + "errors" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "net/http" + "strconv" + "strings" + "time" +) + +// JWTClaims JWT 声明 +type JWTClaims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + TenantID uint `json:"tenant_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// AuthMiddleware 认证中间件 +func Auth(secretKey string) gin.HandlerFunc { + return func(c *gin.Context) { + // 获取 Authorization 头 + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "缺少认证信息", + "message": "请提供 Authorization 头" + }) + c.Abort() + return + } + + // 解析 Bearer Token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "无效的认证格式", + "message": "请使用格式:Bearer {token}" + }) + c.Abort() + return + } + + tokenString := parts[1] + claims := &JWTClaims{} + + // 解析 Token + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // 验证签名算法 + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("不支持的签名算法") + } + return []byte(secretKey), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "无效的 Token", + "message": err.Error(), + }) + c.Abort() + return + } + + // 将解析出的用户信息存入上下文 + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("tenant_id", claims.TenantID) + c.Set("role", claims.Role) + + c.Next() + } +} + +// GenerateToken 生成 JWT Token +func GenerateToken(secretKey string, userID uint, username string, tenantID uint, role string) (string, error) { + expTime := time.Now().Add(24 * time.Hour) // 24 小时过期 + + claims := JWTClaims{ + UserID: userID, + Username: username, + TenantID: tenantID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secretKey)) +} + +// AdminOnly 仅允许管理员访问 +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + c.JSON(http.StatusForbidden, gin.H{ + "error": "未授权", + "message": "请先登录" + }) + c.Abort() + return + } + + roleStr, ok := role.(string) + if !ok { + c.JSON(http.StatusForbidden, gin.H{ + "error": "角色信息无效", + }) + c.Abort() + return + } + + // 检查是否为管理员角色 + isAdminRole := roleStr == "admin" || roleStr == "super_admin" || roleStr == "system_admin" + if !isAdminRole { + c.JSON(http.StatusForbidden, gin.H{ + "error": "权限不足", + "message": "需要管理员权限才能访问此资源" + }) + c.Abort() + return + } + + c.Next() + } +} + +// TenantMiddleware 租户中间件(获取当前租户 ID) +func TenantMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 尝试从路径参数获取租户 ID + tenantIDStr := c.Param("tenant_id") + if tenantIDStr == "" { + // 如果没有路径参数,从用户信息中获取 + if tenantID, exists := c.Get("tenant_id"); exists { + c.Set("current_tenant_id", tenantID) + } + } else { + // 尝试解析租户 ID + if tenantID, err := strconv.ParseUint(tenantIDStr, 10, 32); err == nil { + c.Set("current_tenant_id", uint(tenantID)) + } + } + + c.Next() + } +} + +// PermissionCheck 权限检查中间件 +func PermissionCheck(requiredPermissions []string) gin.HandlerFunc { + return func(c *gin.Context) { + permissionSet, exists := c.Get("permissions") + if !exists { + c.JSON(http.StatusForbidden, gin.H{ + "error": "未授权", + "message": "请先登录" + }) + c.Abort() + return + } + + userPermissions, ok := permissionSet.([]string) + if !ok { + c.JSON(http.StatusForbidden, gin.H{ + "error": "权限信息无效", + }) + c.Abort() + return + } + + // 检查是否拥有所有必需权限 + for _, required := range requiredPermissions { + if !contains(userPermissions, required) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "权限不足", + "message": "需要权限:", + "required": required, + }) + c.Abort() + return + } + } + + c.Next() + } +} + +// contains 检查切片是否包含元素 +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/backend/internal/models/knowledge.go b/backend/internal/models/knowledge.go new file mode 100644 index 0000000..331ca15 --- /dev/null +++ b/backend/internal/models/knowledge.go @@ -0,0 +1,103 @@ +package models + +import ( + "gorm.io/gorm" + "time" +) + +// KnowledgeBase 知识库模型 +type KnowledgeBase struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID uint `gorm:"not null;index;comment:租户 ID" json:"tenant_id"` + Name string `gorm:"size:200;not null;comment:知识库名称" json:"name"` + Slug string `gorm:"unique;size:100;not null;comment:知识库唯一标识" json:"slug"` + Description *string `gorm:"type:text;comment:知识库描述" json:"description"` + Icon *string `gorm:"size:100;comment:图标" json:"icon"` + SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` + Status string `gorm:"default:'draft';comment:状态 (draft/published/archived)" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + // 关联 + Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` + KnowledgeItems []KnowledgeItem `gorm:"foreignKey:KnowledgeBaseID" json:"items,omitempty"` +} + +// TableName 指定表名 +func (KnowledgeBase) TableName() string { + return "knowledge_bases" +} + +// KnowledgeItem 知识库条目模型 +type KnowledgeItem struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID uint `gorm:"not null;index;comment:租户 ID" json:"tenant_id"` + KnowledgeBaseID uint `gorm:"not null;index;comment:知识库 ID" json:"knowledge_base_id"` + Title string `gorm:"size:500;not null;comment:标题" json:"title"` + Slug string `gorm:"size:200;not null;comment:唯一标识" json:"slug"` + Content string `gorm:"type:longtext;not null;comment:内容 (Markdown)" json:"content"` + Summary *string `gorm:"type:text;comment:摘要" json:"summary"` + Tags *string `gorm:"type:json;comment:标签 (JSON)" json:"tags"` + Metadata *string `gorm:"type:text;comment:元数据 (JSON)" json:"metadata"` + AuthorID *uint `gorm:"comment:作者 ID" json:"author_id"` + Status string `gorm:"default:'draft';index;comment:状态 (draft/review/published/archived)" json:"status"` + Views int `gorm:"default:0;comment:浏览次数" json:"views"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + // 关联 + Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` + Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"` + KnowledgeBase *KnowledgeBase `gorm:"foreignKey:KnowledgeBaseID" json:"knowledge_base,omitempty"` +} + +// TableName 指定表名 +func (KnowledgeItem) TableName() string { + return "knowledge_items" +} + +// KnowledgeCategory 知识库分类 +type KnowledgeCategory struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + KnowledgeBaseID uint `gorm:"not null;index" json:"knowledge_base_id"` + ParentID *uint `gorm:"index" json:"parent_id"` + Name string `gorm:"size:200;not null" json:"name"` + Slug string `gorm:"size:100;not null" json:"slug"` + Description *string `gorm:"type:text" json:"description"` + Icon *string `gorm:"size:100" json:"icon"` + SortOrder int `gorm:"default:0" json:"sort_order"` + Status string `gorm:"default:'active'" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` +} + +// TableName 指定表名 +func (KnowledgeCategory) TableName() string { + return "knowledge_categories" +} + +// 知识库状态枚举 +const ( + KBStatusDraft = "draft" + KBStatusPublished = "published" + KBStatusArchived = "archived" +) + +// 知识库条目状态枚举 +const ( + KnowledgeStatusDraft = "draft" + KnowledgeStatusReview = "review" + KnowledgeStatusPublished = "published" + KnowledgeStatusArchived = "archived" +) + +// KnowledgeSearchResult 知识库搜索结果 +type KnowledgeSearchResult struct { + Item KnowledgeItem `json:"item"` + Score float64 `json:"score"` // 相关度得分 + Matched string `json:"matched"` // 匹配的文本片段 + Category string `json:"category"` // 分类 +} diff --git a/backend/internal/models/resource.go b/backend/internal/models/resource.go new file mode 100644 index 0000000..eb253f4 --- /dev/null +++ b/backend/internal/models/resource.go @@ -0,0 +1,60 @@ +package models + +import ( + "time" +) + +// Resource 资源模型(系统所有可访问的资源) +type Resource struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"not null;index:idx_tenant_resource" json:"tenant_id"` + Name string `gorm:"size:100;not null" json:"name"` // 资源名称 + DisplayName string `gorm:"size:200" json:"display_name"` + Description string `gorm:"type:text" json:"description"` + + // 资源标识 + Code string `gorm:"size:100;not null;uniqueIndex:idx_tenant_resource_code" json:"code"` // 资源代码 + Type string `gorm:"size:50;not null" json:"type"` // api, page, button, data 等 + Group string `gorm:"size:100" json:"group"` // 所属分组 + + // 权限配置 + Actions []string `gorm:"type:jsonb" json:"actions"` // 允许的操作:create, read, update, delete, export 等 + Path string `gorm:"size:500" json:"path"` // 资源路径或 API 端点 + ParentID *uint `gorm:"index" json:"parent_id,omitempty"` // 父资源 ID + + // 状态 + Status string `gorm:"size:20;default:'enabled'" json:"status"` // enabled, disabled + IsSystem bool `gorm:"default:false" json:"is_system"` // 是否系统资源 + + // 资源层级 + Level int `gorm:"default:0" json:"level"` // 层级深度 + SortOrder int `gorm:"default:0" json:"sort_order"` // 排序 + + // 时间戳 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"` + + // 关联 + Tenant Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` + Roles []Role `gorm:"many2many:role_resources;" json:"roles,omitempty"` + Parent *Resource `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []Resource `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +// Permission 权限检查辅助结构 +type Permission struct { + ResourceCode string `json:"resource_code"` + Action string `json:"action"` // create, read, update, delete, manage 等 + TenantID uint `json:"tenant_id,omitempty"` + UserID uint `json:"user_id,omitempty"` +} + +// HasPermission 检查用户是否有权限(需要在 handler 中实现) +func HasPermission(userID, tenantID uint, resourceCode, action string) bool { + // TODO: 实现权限检查逻辑 + // 1. 查询用户角色 + // 2. 查询角色资源 + // 3. 检查资源是否包含该操作 + return false +} diff --git a/backend/internal/models/role.go b/backend/internal/models/role.go new file mode 100644 index 0000000..5ba42f3 --- /dev/null +++ b/backend/internal/models/role.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" +) + +// Role 角色模型(基于租户) +type Role struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"not null;index:idx_tenant_role" json:"tenant_id"` + Name string `gorm:"size:50;not null;uniqueIndex:idx_tenant_role_name" json:"name"` // admin, manager, agent, viewer 等 + DisplayName string `gorm:"size:100" json:"display_name"` // 显示名称 + Description string `gorm:"type:text" json:"description"` + + // 权限配置 + Code string `gorm:"size:50;uniqueIndex" json:"code"` // 角色代码 + Level int `gorm:"default:1" json:"level"` // 权限等级 1-100 + Permissions JSONMap `gorm:"type:jsonb" json:"permissions"` // 权限列表 + + // 状态 + IsGlobal bool `gorm:"default:false" json:"is_global"` // 是否全局角色 + Status string `gorm:"size:20;default:'active'" json:"status"` // active, inactive + + // 资源配置 + Resources []Resource `gorm:"many2many:role_resources;" json:"resources,omitempty"` + + // 时间戳 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"` + + // 关联 + Tenant Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` + Users []User `gorm:"foreignKey:RoleID" json:"users,omitempty"` +} + +// RoleID 添加到 User 模型 +// 需要在 User 模型中添加 RoleID 字段 diff --git a/backend/internal/models/ticket.go b/backend/internal/models/ticket.go new file mode 100644 index 0000000..a26b475 --- /dev/null +++ b/backend/internal/models/ticket.go @@ -0,0 +1,108 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Ticket 工单模型 +type Ticket struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TicketNumber string `gorm:"unique;size:50;not null;comment:工单编号" json:"ticket_number"` + TenantID uint `gorm:"not null;index;comment:租户 ID" json:"tenant_id"` + UserID *uint `gorm:"index;comment:用户 ID" json:"user_id"` + Title string `gorm:"size:500;not null;comment:工单标题" json:"title"` + Description string `gorm:"type:text;not null;comment:工单描述" json:"description"` + Category *string `gorm:"size:100;comment:工单分类" json:"category"` + Priority string `gorm:"default:'medium';index;comment:优先级 (low/medium/high/urgent)" json:"priority"` + Status string `gorm:"default:'open';index;comment:状态 (open/pending/in_progress/resolved/closed)" json:"status"` + AssignedTo *uint `gorm:"index;comment:指配给" json:"assigned_to"` + DueDate *time.Time `gorm:"comment:截止日期" json:"due_date"` + Resolution *string `gorm:"type:text;comment:解决方案" json:"resolution"` + Metadata *string `gorm:"type:text;comment:元数据 (JSON)" json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + // 关联 + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Assignee *User `gorm:"foreignKey:AssignedTo" json:"assignee,omitempty"` + Tenat *Tenant `gorm:"foreignKey:TenatID" json:"tenant,omitempty"` + Messages []TicketMessage `json:"messages,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// TableName 指定表名 +func (Ticket) TableName() string { + return "tickets" +} + +// TicketMessage 工单消息 +type TicketMessage struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TicketID uint `gorm:"not null;index;comment:工单 ID" json:"ticket_id"` + UserID uint `gorm:"not null;comment:发送者 ID" json:"user_id"` + Content string `gorm:"type:text;not null;comment:消息内容" json:"content"` + ContentType string `gorm:"default:'text';comment:类型 (text/image/file)" json:"content_type"` + ContentURL *string `gorm:"size:1000;comment:内容 URL" json:"content_url"` + IsInternal bool `gorm:"default:false;comment:是否内部备注" json:"is_internal"` + Metadata *string `gorm:"type:text;comment:元数据" json:"metadata"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + Ticket *Ticket `gorm:"foreignKey:TicketID" json:"ticket,omitempty"` +} + +// TableName 指定表名 +func (TicketMessage) TableName() string { + return "ticket_messages" +} + +// Attachment 附件 +type Attachment struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID uint `gorm:"not null;index;comment:租户 ID" json:"tenant_id"` + TicketID uint `gorm:"not null;index;comment:工单 ID" json:"ticket_id"` + FileName string `gorm:"size:500;not null;comment:文件名" json:"file_name"` + FileURL string `gorm:"not null;comment:文件 URL" json:"file_url"` + FileSize uint64 `gorm:"comment:文件大小 (字节)" json:"file_size"` + MIMEType string `gorm:"size:100;comment:MIME 类型" json:"mime_type"` + Description *string `gorm:"comment:描述" json:"description"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + + Ticket *Ticket `gorm:"foreignKey:TicketID" json:"ticket,omitempty"` +} + +// TableName 指定表名 +func (Attachment) TableName() string { + return "attachments" +} + +// 工单状态枚举 +const ( + TicketStatusOpen = "open" + TicketStatusPending = "pending" + TicketStatusInProgress = "in_progress" + TicketStatusResolved = "resolved" + TicketStatusClosed = "closed" +) + +// 工单优先级枚举 +const ( + TicketPriorityLow = "low" + TicketPriorityMedium = "medium" + TicketPriorityHigh = "high" + TicketPriorityUrgent = "urgent" +) + +// 工单分类枚举 +const ( + TicketCategoryTechnical = "technical" // 技术问题 + TicketCategoryBug = "bug" // Bug 报告 + TicketCategoryFeature = "feature" // 功能请求 + TicketCategorySupport = "support" // 技术支持 + TicketCategoryBilling = "billing" // 计费问题 + TicketCategoryOther = "other" // 其他 +) diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index ac9b082..2646850 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -19,7 +19,9 @@ type User struct { Bio string `gorm:"type:text" json:"bio"` // 角色和权限 - Role string `gorm:"size:20;default:'user'" json:"role"` // super_admin, admin, agent, user + Role string `gorm:"size:20;default:'user'" json:"role"` // super_admin, admin, agent, user (legacy) + RoleID *uint `json:"role_id,omitempty"` // 关联的角色 ID (新的 RBAC) + Roles []Role `gorm:"many2many:user_roles;" json:"roles,omitempty"` // 用户关联的角色(多对多) Status string `gorm:"size:20;default:'active'" json:"status"` // active, inactive, banned IsVerified bool `gorm:"default:false" json:"is_verified"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fca50e4..5148e2c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -2,11 +2,11 @@ package router import ( "time" + + "github.com/gin-gonic/gin" "smart-customer-service/config" "smart-customer-service/internal/handlers" "smart-customer-service/internal/middleware" - - "github.com/gin-gonic/gin" ) type Router struct { @@ -21,8 +21,9 @@ func New(cfg *config.Config) *Router { } } +// SetupRoutes 配置所有路由 func (r *Router) SetupRoutes() *gin.Engine { - // 设置Gin模式 + // 设置 Gin 模式 if r.cfg.Server.Mode == "release" { gin.SetMode(gin.ReleaseMode) } @@ -34,63 +35,71 @@ func (r *Router) SetupRoutes() *gin.Engine { router.Use(middleware.Logger()) router.Use(middleware.Recovery()) - // API路由组 + // ============ API 路由 ============ api := router.Group("/api") { - // 公共路由(无需认证) + // === 公共路由(无需认证) === public := api.Group("/v1") { + // 认证 public.POST("/auth/login", r.handlers.Auth.Login) public.POST("/auth/register", r.handlers.Auth.Register) public.POST("/auth/refresh", r.handlers.Auth.RefreshToken) - - // 租户相关 + + // 租户注册 public.POST("/tenants/register", r.handlers.Tenant.Register) - public.GET("/tenants/:id", r.handlers.Tenant.GetTenantInfo) } - // 需要认证的路由 + // === 需要认证的路由 === protected := api.Group("/v1") protected.Use(middleware.Auth(r.cfg.JWT.Secret)) { + // 租户管理 + r.setupTenantRoutes(protected) + // 用户管理 - protected.GET("/users/profile", r.handlers.User.GetProfile) - protected.PUT("/users/profile", r.handlers.User.UpdateProfile) - + r.setupUserRoutes(protected) + + // 角色管理 + r.setupRoleRoutes(protected) + + // 资源管理 + r.setupResourceRoutes(protected) + // 会话管理 - protected.GET("/conversations", r.handlers.Conversation.List) - protected.POST("/conversations", r.handlers.Conversation.Create) - protected.GET("/conversations/:id", r.handlers.Conversation.Get) - protected.GET("/conversations/:id/messages", r.handlers.Conversation.GetMessages) - - // 消息管理 - protected.POST("/messages", r.handlers.Message.Send) - + r.setupConversationRoutes(protected) + // 工单管理 - protected.GET("/tickets", r.handlers.Ticket.List) - protected.POST("/tickets", r.handlers.Ticket.Create) - protected.GET("/tickets/:id", r.handlers.Ticket.Get) - protected.PUT("/tickets/:id", r.handlers.Ticket.Update) - + r.setupTicketRoutes(protected) + // 知识库管理 - protected.GET("/knowledge", r.handlers.Knowledge.List) - protected.POST("/knowledge", r.handlers.Knowledge.Create) - protected.PUT("/knowledge/:id", r.handlers.Knowledge.Update) - protected.DELETE("/knowledge/:id", r.handlers.Knowledge.Delete) + r.setupKnowledgeRoutes(protected) } - // 管理员路由 + // === 管理员路由 === admin := api.Group("/admin") admin.Use(middleware.Auth(r.cfg.JWT.Secret)) admin.Use(middleware.AdminOnly()) { + // 全局租户管理 admin.GET("/tenants", r.handlers.Tenant.ListAll) admin.PUT("/tenants/:id/status", r.handlers.Tenant.UpdateStatus) + + // 全局用户管理 + admin.GET("/users", r.handlers.User.ListAll) + + // 统计数据 admin.GET("/stats", r.handlers.Admin.GetStats) + admin.GET("/stats/tenants", r.handlers.Admin.GetTenantStats) + admin.GET("/stats/users", r.handlers.Admin.GetUserStats) + + // 系统配置 + admin.GET("/config", r.handlers.Admin.GetConfig) + admin.PUT("/config", r.handlers.Admin.UpdateConfig) } } - // 健康检查 + // === 健康检查 === router.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", @@ -98,5 +107,109 @@ func (r *Router) SetupRoutes() *gin.Engine { }) }) + // === 版本信息 === + router.GET("/version", func(c *gin.Context) { + c.JSON(200, gin.H{ + "version": "1.0.0", + "build": "development", + }) + }) + return router } + +// setupTenantRoutes 配置租户路由 +func (r *Router) setupTenantRoutes(g *gin.RouterGroup) { + tenants := g.Group("/tenants") + { + tenants.GET("", r.handlers.Tenant.List) // 列表 + tenants.GET("/:id", r.handlers.Tenant.Get) // 详情 + tenants.PUT("/:id", r.handlers.Tenant.Update) // 更新 + tenants.DELETE("/:id", r.handlers.Tenant.Delete) // 删除 + tenants.POST("/:id/activate", r.handlers.Tenant.Activate) // 激活 + tenants.POST("/:id/suspend", r.handlers.Tenant.Suspend) // 暂停 + tenants.GET("/:id/stats", r.handlers.Tenant.GetStats) // 统计 + } +} + +// setupUserRoutes 配置用户路由 +func (r *Router) setupUserRoutes(g *gin.RouterGroup) { + users := g.Group("/users") + { + users.GET("", r.handlers.User.List) // 列表 + users.POST("", r.handlers.User.Create) // 创建 + users.GET("/:id", r.handlers.User.Get) // 详情 + users.PUT("/:id", r.handlers.User.Update) // 更新 + users.DELETE("/:id", r.handlers.User.Delete) // 删除 + users.PUT("/:id/change-password", r.handlers.User.ChangePassword) // 修改密码 + users.POST("/:id/assign-roles", r.handlers.User.AssignRoles) // 分配角色 + + // 个人资料 + users.GET("/profile", r.handlers.User.GetProfile) + users.PUT("/profile", r.handlers.User.UpdateProfile) + } +} + +// setupRoleRoutes 配置角色路由 +func (r *Router) setupRoleRoutes(g *gin.RouterGroup) { + roles := g.Group("/roles") + { + roles.GET("", r.handlers.Role.List) // 列表 + roles.POST("", r.handlers.Role.Create) // 创建 + roles.GET("/:id", r.handlers.Role.Get) // 详情 + roles.PUT("/:id", r.handlers.Role.Update) // 更新 + roles.DELETE("/:id", r.handlers.Role.Delete) // 删除 + roles.POST("/:id/assign-resources", r.handlers.Role.AssignResources) // 分配资源 + roles.GET("/:id/permissions", r.handlers.Role.GetPermissions) // 获取权限 + } +} + +// setupResourceRoutes 配置资源路由 +func (r *Router) setupResourceRoutes(g *gin.RouterGroup) { + resources := g.Group("/resources") + { + resources.GET("", r.handlers.Resource.List) // 列表 + resources.POST("", r.handlers.Resource.Create) // 创建 + resources.GET("/:id", r.handlers.Resource.Get) // 详情 + resources.PUT("/:id", r.handlers.Resource.Update) // 更新 + resources.DELETE("/:id", r.handlers.Resource.Delete) // 删除 + resources.GET("/tree", r.handlers.Resource.GetTree) // 资源树 + resources.GET("/code/:code", r.handlers.Resource.GetResourceByCode) // 通过代码查询 + resources.POST("/check-permission", r.handlers.Resource.CheckPermission) // 权限检查 + } +} + +// setupConversationRoutes 配置会话路由 +func (r *Router) setupConversationRoutes(g *gin.RouterGroup) { + g.GET("/conversations", r.handlers.Conversation.List) + g.POST("/conversations", r.handlers.Conversation.Create) + g.GET("/conversations/:id", r.handlers.Conversation.Get) + g.GET("/conversations/:id/messages", r.handlers.Conversation.GetMessages) +} + +// setupTicketRoutes 配置工单路由 +func (r *Router) setupTicketRoutes(g *gin.RouterGroup) { + g.GET("/tickets", r.handlers.Ticket.List) + g.POST("/tickets", r.handlers.Ticket.Create) + g.GET("/tickets/:id", r.handlers.Ticket.Get) + g.PUT("/tickets/:id", r.handlers.Ticket.Update) +} + +// setupKnowledgeRoutes 配置知识库路由 +func (r *Router) setupKnowledgeRoutes(g *gin.RouterGroup) { + // 知识库管理 + g.GET("/knowledge/bases", r.handlers.Knowledge.ListKnowledgeBases) + g.POST("/knowledge/bases", r.handlers.Knowledge.CreateKnowledgeBase) + g.PUT("/knowledge/bases/:id", r.handlers.Knowledge.UpdateKnowledgeBase) + g.DELETE("/knowledge/bases/:id", r.handlers.Knowledge.DeleteKnowledgeBase) + + // 知识条目管理 + g.GET("/knowledge/items", r.handlers.Knowledge.ListKnowledgeItems) + g.POST("/knowledge/items", r.handlers.Knowledge.CreateKnowledgeItem) + g.PUT("/knowledge/items/:id", r.handlers.Knowledge.UpdateKnowledgeItem) + g.DELETE("/knowledge/items/:id", r.handlers.Knowledge.DeleteKnowledgeItem) + + // 搜索和统计 + g.GET("/knowledge/search", r.handlers.Knowledge.Search) + g.GET("/knowledge/stats", r.handlers.Knowledge.GetStats) +} diff --git a/backend/migrations/001_complete_schema.sql b/backend/migrations/001_complete_schema.sql new file mode 100644 index 0000000..e9adb30 --- /dev/null +++ b/backend/migrations/001_complete_schema.sql @@ -0,0 +1,310 @@ +-- ============================================================ +-- 智能客服系统 - 完整数据库迁移 +-- 数据库:MySQL 5.7+ +-- 作者:OpenClaw Team +-- 日期:2026-03-01 +-- ============================================================ + +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS `openclaw` +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +USE `openclaw`; + +-- ============================================================ +-- 租户管理表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `tenants` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '租户名称', + `slug` VARCHAR(100) UNIQUE NOT NULL COMMENT '租户唯一标识', + `email` VARCHAR(255) NOT NULL COMMENT '管理员邮箱', + `phone` VARCHAR(50) DEFAULT NULL COMMENT '联系电话', + `logo` VARCHAR(500) DEFAULT NULL COMMENT 'Logo URL', + `website` VARCHAR(500) DEFAULT NULL COMMENT '官方网站', + `description` TEXT DEFAULT NULL COMMENT '租户描述', + `status` ENUM('pending', 'active', 'suspended', 'archived') DEFAULT 'pending', + `plan` ENUM('free', 'basic', 'pro', 'enterprise') DEFAULT 'free', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + UNIQUE KEY `uk_slug` (`slug`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表'; + +-- ============================================================ +-- 用户表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '所属租户 ID', + `username` VARCHAR(100) NOT NULL COMMENT '用户名', + `email` VARCHAR(255) NOT NULL COMMENT '邮箱', + `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) DEFAULT NULL COMMENT '全名', + `avatar` VARCHAR(500) DEFAULT NULL COMMENT '头像 URL', + `phone` VARCHAR(50) DEFAULT NULL COMMENT '手机号', + `role` ENUM('owner', 'admin', 'member', 'guest') DEFAULT 'member', + `status` ENUM('active', 'inactive', 'suspended', 'deleted') DEFAULT 'active', + `last_login_at` TIMESTAMP DEFAULT NULL, + `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录 IP', + `remember_token` VARCHAR(100) DEFAULT NULL COMMENT '长期登录 Token', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + UNIQUE KEY `uk_username` (`username`), + UNIQUE KEY `uk_email` (`email`), + KEY `idx_tenant_status` (`tenant_id`, `status`) USING BTREE, + CONSTRAINT `fk_users_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- ============================================================ +-- 角色表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `roles` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `tenant_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '租户 ID(NULL 为全局角色)', + `name` VARCHAR(100) NOT NULL COMMENT '角色名称', + `code` VARCHAR(100) NOT NULL COMMENT '角色代码', + `description` TEXT DEFAULT NULL COMMENT '角色描述', + `is_global` BOOLEAN DEFAULT FALSE COMMENT '是否为全局角色', + `is_system` BOOLEAN DEFAULT FALSE COMMENT '是否为系统内置角色', + `status` ENUM('active', 'inactive', 'archived') DEFAULT 'active', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + UNIQUE KEY `uk_code` (`code`), + KEY `idx_tenant` (`tenant_id`), + CONSTRAINT `fk_roles_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'; + +-- 初始化系统内置角色 +INSERT INTO `roles` (`name`, `code`, `description`, `is_global`, `is_system`, `status`) VALUES +('系统管理员', 'system_admin', '拥有系统全部权限', TRUE, TRUE, 'active'), +('超级管理员', 'super_admin', '租户超级管理员', FALSE, TRUE, 'active'), +('普通用户', 'user', '普通用户角色', FALSE, TRUE, 'active'), +('访客', 'guest', '访客角色(只读)', FALSE, TRUE, 'active'); + +-- ============================================================ +-- 资源/权限表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `resources` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `tenant_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '租户 ID', + `parent_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '父资源 ID', + `name` VARCHAR(200) NOT NULL COMMENT '资源名称', + `code` VARCHAR(200) NOT NULL COMMENT '资源代码(唯一)', + `type` ENUM('api', 'page', 'button', 'data') NOT NULL COMMENT '资源类型', + `group` VARCHAR(100) DEFAULT NULL COMMENT '资源分组', + `path` VARCHAR(500) DEFAULT NULL COMMENT 'API/页面路径', + `method` VARCHAR(10) DEFAULT NULL COMMENT 'API 请求方法', + `description` TEXT DEFAULT NULL COMMENT '资源描述', + `icon` VARCHAR(100) DEFAULT NULL COMMENT '图标', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `level` INT DEFAULT 0 COMMENT '层级深度', + `lft` INT DEFAULT NULL COMMENT '左值(用于树查询)', + `rgt` INT DEFAULT NULL COMMENT '右值(用于树查询)', + `is_system` BOOLEAN DEFAULT FALSE COMMENT '是否为系统资源', + `status` ENUM('active', 'inactive', 'archived') DEFAULT 'active', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + UNIQUE KEY `uk_code` (`code`), + KEY `idx_tenant_parent` (`tenant_id`, `parent_id`), + KEY `idx_group` (`group`), + KEY `idx_type` (`type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源/权限表'; + +-- 初始化系统资源(示例) +INSERT INTO `resources` (`name`, `code`, `type`, `group`, `path`, `method`, `description`, `is_system`, `status`) VALUES +('租户管理', 'tenant:manage', 'api', 'tenant', '/api/v1/tenants', '', '租户列表查询', TRUE, 'active'), +('租户创建', 'tenant:create', 'api', 'tenant', '/api/v1/tenants', 'POST', '创建租户', TRUE, 'active'), +('租户更新', 'tenant:update', 'api', 'tenant', '/api/v1/tenants/*', 'PUT', '更新租户', TRUE, 'active'), +('租户删除', 'tenant:delete', 'api', 'tenant', '/api/v1/tenants/*', 'DELETE', '删除租户', TRUE, 'active'), +('用户管理', 'user:manage', 'api', 'user', '/api/v1/users', '', '用户列表查询', TRUE, 'active'), +('用户创建', 'user:create', 'api', 'user', '/api/v1/users', 'POST', '创建用户', TRUE, 'active'), +('角色管理', 'role:manage', 'api', 'role', '/api/v1/roles', '', '角色管理', TRUE, 'active'), +('资源管理', 'resource:manage', 'api', 'resource', '/api/v1/resources', '', '资源管理', TRUE, 'active'), +('工单管理', 'ticket:manage', 'api', 'ticket', '/api/v1/tickets', '', '工单管理', TRUE, 'active'), +('会话管理', 'conversation:manage', 'api', 'conversation', '/api/v1/conversations', '', '会话管理', TRUE, 'active'), +('知识库管理', 'knowledge:manage', 'api', 'knowledge', '/api/v1/knowledge', '', '知识库管理', TRUE, 'active'); + +-- ============================================================ +-- 角色资源关联表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `role_resources` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `role_id` BIGINT UNSIGNED NOT NULL, + `resource_id` BIGINT UNSIGNED NOT NULL, + `actions` VARCHAR(255) DEFAULT NULL COMMENT '允许的操作 (JSON)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY `uk_role_resource` (`role_id`, `resource_id`), + KEY `idx_resource` (`resource_id`), + CONSTRAINT `fk_role_resources_role` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_role_resources_resource` FOREIGN KEY (`resource_id`) REFERENCES `resources`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色资源关联表'; + +-- ============================================================ +-- 用户角色关联表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `user_roles` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `user_id` BIGINT UNSIGNED NOT NULL, + `role_id` BIGINT UNSIGNED NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY `uk_user_role` (`user_id`, `role_id`), + KEY `idx_role` (`role_id`), + CONSTRAINT `fk_user_roles_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_roles_role` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色关联表'; + +-- ============================================================ +-- 会话表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `conversations` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + `user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '用户 ID', + `title` VARCHAR(500) DEFAULT NULL COMMENT '会话标题', + `topic` VARCHAR(200) DEFAULT NULL COMMENT '会话主题', + `status` ENUM('active', 'closed', 'archived') DEFAULT 'active', + `metadata` JSON DEFAULT NULL COMMENT '元数据', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + KEY `idx_tenant` (`tenant_id`), + KEY `idx_user` (`user_id`), + CONSTRAINT `fk_conversations_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话表'; + +-- ============================================================ +-- 消息表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `messages` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `conversation_id` BIGINT UNSIGNED NOT NULL COMMENT '会话 ID', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + `user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '用户 ID', + `role` ENUM('user', 'assistant', 'system') NOT NULL COMMENT '消息角色', + `content` TEXT NOT NULL COMMENT '消息内容', + `content_type` ENUM('text', 'image', 'file') DEFAULT 'text', + `content_url` VARCHAR(1000) DEFAULT NULL COMMENT '内容 URL', + `metadata` JSON DEFAULT NULL COMMENT '消息元数据', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + KEY `idx_conversation` (`conversation_id`), + KEY `idx_tenant` (`tenant_id`), + CONSTRAINT `fk_messages_conversation` FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_messages_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表'; + +-- ============================================================ +-- 工单表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `tickets` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `ticket_number` VARCHAR(50) UNIQUE NOT NULL COMMENT '工单编号', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + `user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '用户 ID', + `title` VARCHAR(500) NOT NULL COMMENT '工单标题', + `description` TEXT NOT NULL COMMENT '工单描述', + `category` VARCHAR(100) DEFAULT NULL COMMENT '工单分类', + `priority` ENUM('low', 'medium', 'high', 'urgent') DEFAULT 'medium', + `status` ENUM('open', 'pending', 'in_progress', 'resolved', 'closed') DEFAULT 'open', + `assigned_to` BIGINT UNSIGNED DEFAULT NULL COMMENT '指配给', + `due_date` DATETIME DEFAULT NULL COMMENT '截止日期', + `resolution` TEXT DEFAULT NULL COMMENT '解决方案', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + KEY `idx_tenant` (`tenant_id`), + KEY `idx_status` (`status`), + KEY `idx_assigned` (`assigned_to`), + CONSTRAINT `fk_tickets_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工单表'; + +-- ============================================================ +-- 知识库分类表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `knowledge_bases` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识库名称', + `slug` VARCHAR(100) NOT NULL COMMENT '知识库唯一标识', + `description` TEXT DEFAULT NULL COMMENT '知识库描述', + `icon` VARCHAR(100) DEFAULT NULL COMMENT '图标', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + UNIQUE KEY `uk_slug` (`slug`), + KEY `idx_tenant` (`tenant_id`), + CONSTRAINT `fk_kb_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库分类'; + +-- ============================================================ +-- 知识库项表 +-- ============================================================ +CREATE TABLE IF NOT EXISTS `knowledge_items` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `knowledge_base_id` BIGINT UNSIGNED NOT NULL COMMENT '知识库 ID', + `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + `title` VARCHAR(500) NOT NULL COMMENT '标题', + `slug` VARCHAR(200) NOT NULL COMMENT '唯一标识', + `content` LONGTEXT NOT NULL COMMENT '内容(Markdown)', + `summary` TEXT DEFAULT NULL COMMENT '摘要', + `tags` JSON DEFAULT NULL COMMENT '标签', + `metadata` JSON DEFAULT NULL COMMENT '元数据', + `author_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '作者 ID', + `status` ENUM('draft', 'review', 'published', 'archived') DEFAULT 'draft', + `views` INT DEFAULT 0 COMMENT '浏览次数', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP DEFAULT NULL, + + KEY `idx_kb` (`knowledge_base_id`), + KEY `idx_tenant` (`tenant_id`), + KEY `idx_status` (`status`), + KEY `idx_tags` (`tags`), + CONSTRAINT `fk_ki_kb` FOREIGN KEY (`knowledge_base_id`) REFERENCES `knowledge_bases`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ki_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库项'; + +-- ============================================================ +-- 全表索引优化 +-- ============================================================ + +-- 用户角色查询优化 +CREATE INDEX `idx_users_roles_composite` ON `user_roles` (`user_id`, `created_at`); + +-- 消息时间查询优化 +CREATE INDEX `idx_messages_created` ON `messages` (`created_at`); + +-- 会话活跃状态优化 +CREATE INDEX `idx_conversations_active` ON `conversations` (`status`, `created_at`); + +-- 工单状态查询优化 +CREATE INDEX `idx_tickets_status_priority` ON `tickets` (`status`, `priority`, `created_at`); + +-- 知识库全文索引(可选) +ALTER TABLE `knowledge_items` ADD FULLTEXT INDEX `idx_content_fulltext` (`content`); +ALTER TABLE `knowledge_items` ADD FULLTEXT INDEX `idx_title_content` (`title`, `content`); + +-- ============================================================ +-- 完成迁移 +-- ============================================================ + +-- 输出迁移信息 +SELECT '✅ 数据库迁移完成!' AS status;