package handler import ( "net/http" "strconv" "strings" "unicode/utf8" "github.com/gin-gonic/gin" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" ) type KnowledgeHandler struct{} func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} } func requireKnowledgeManager(c *gin.Context) bool { if middleware.HasAnyRole(c, "admin", "supervisor") { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理知识库"}) return false } func hasKnowledgeCapacity(tenantID uint) (bool, error) { var tenant model.Tenant if err := model.DB.First(&tenant, tenantID).Error; err != nil { return false, err } if tenant.PlanID == nil { return true, nil } var plan model.Plan if err := model.DB.First(&plan, *tenant.PlanID).Error; err != nil { return false, err } if plan.KBLimit == 0 { return true, nil } var count int64 if err := model.DB.Model(&model.KnowledgeEntry{}).Where("tenant_id = ?", tenantID).Count(&count).Error; err != nil { return false, err } return count < int64(plan.KBLimit), nil } type CategoryListItem struct { model.Category EntryCount int64 `json:"entry_count"` // 本分类直属条目数 TotalCount int64 `json:"total_count"` // 含子分类汇总 } type CategoryReq struct { Name string `json:"name" binding:"required"` ParentID *uint `json:"parent_id"` } func (h *KnowledgeHandler) ListCategories(c *gin.Context) { tenantID := middleware.GetTenantID(c) var categories []model.Category model.DB.Where("tenant_id = ?", tenantID).Order("id asc").Find(&categories) type countRow struct { CategoryID uint Cnt int64 } var rows []countRow model.DB.Model(&model.KnowledgeEntry{}). Select("category_id, COUNT(*) as cnt"). Where("tenant_id = ?", tenantID). Group("category_id"). Scan(&rows) directMap := map[uint]int64{} for _, r := range rows { directMap[r.CategoryID] = r.Cnt } // 子分类映射 parent -> children childrenMap := map[uint][]uint{} for _, cat := range categories { if cat.ParentID != nil { childrenMap[*cat.ParentID] = append(childrenMap[*cat.ParentID], cat.ID) } } var rollup func(id uint) int64 rollup = func(id uint) int64 { sum := directMap[id] for _, child := range childrenMap[id] { sum += rollup(child) } return sum } var total int64 model.DB.Model(&model.KnowledgeEntry{}).Where("tenant_id = ?", tenantID).Count(&total) items := make([]CategoryListItem, 0, len(categories)) for _, cat := range categories { items = append(items, CategoryListItem{ Category: cat, EntryCount: directMap[cat.ID], TotalCount: rollup(cat.ID), }) } middleware.JSON(c, gin.H{"list": items, "total_entries": total}) } func validateCategoryName(name string) string { name = strings.TrimSpace(name) n := utf8.RuneCountInString(name) if n < 2 || n > 30 { return "" } return name } // 校验父分类:最多两级(父分类自身 parent_id 必须为空) func validateCategoryParent(tenantID uint, parentID *uint) error { if parentID == nil { return nil } var parent model.Category if err := model.DB.Where("id = ? AND tenant_id = ?", *parentID, tenantID).First(&parent).Error; err != nil { return errParentNotFound } if parent.ParentID != nil { return errCategoryDepth } return nil } var ( errParentNotFound = errCat("父分类不存在") errCategoryDepth = errCat("仅支持两级分类,不能挂到子分类下") errCategoryName = errCat("分类名称需 2-30 个字符") errCategoryDup = errCat("同级下分类名称已存在") ) type catError string func errCat(s string) catError { return catError(s) } func (e catError) Error() string { return string(e) } func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID uint) bool { q := model.DB.Model(&model.Category{}).Where("tenant_id = ? AND name = ?", tenantID, name) if parentID == nil { q = q.Where("parent_id IS NULL") } else { q = q.Where("parent_id = ?", *parentID) } if excludeID > 0 { q = q.Where("id <> ?", excludeID) } var n int64 q.Count(&n) return n > 0 } func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { if !requireKnowledgeManager(c) { return } var req CategoryReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } name := validateCategoryName(req.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": errCategoryName.Error()}) return } tenantID := middleware.GetTenantID(c) if err := validateCategoryParent(tenantID, req.ParentID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } if isCategoryNameTaken(tenantID, name, req.ParentID, 0) { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": errCategoryDup.Error()}) return } category := model.Category{ TenantID: tenantID, Name: name, ParentID: req.ParentID, } if err := model.DB.Create(&category).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } middleware.JSON(c, category) } func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) { if !requireKnowledgeManager(c) { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") var category model.Category if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&category).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"}) return } var req CategoryReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } name := validateCategoryName(req.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": errCategoryName.Error()}) return } // 禁止把自己设为自己的子级;改父级时校验 if req.ParentID != nil && *req.ParentID == category.ID { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能将分类设为自己的子分类"}) return } // 若本分类已有子分类,则不能再挂到其它父级下(保持最多两级) if req.ParentID != nil { var childCnt int64 model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt) if childCnt > 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "已有子分类的节点不能改为子分类"}) return } } if err := validateCategoryParent(tenantID, req.ParentID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } if isCategoryNameTaken(tenantID, name, req.ParentID, category.ID) { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": errCategoryDup.Error()}) return } updates := map[string]interface{}{ "name": name, "parent_id": req.ParentID, } if err := model.DB.Model(&category).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } model.DB.First(&category, category.ID) middleware.JSON(c, category) } func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) { if !requireKnowledgeManager(c) { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") var category model.Category if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&category).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"}) return } var childCnt int64 model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt) if childCnt > 0 { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "请先删除或移走子分类"}) return } // 本分类及不应有子分类后,检查条目 var entryCnt int64 model.DB.Model(&model.KnowledgeEntry{}).Where("category_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&entryCnt) if entryCnt > 0 { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "分类下仍有知识条目,请先删除或迁移条目"}) return } if err := model.DB.Delete(&category).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"}) return } middleware.JSON(c, gin.H{"message": "已删除"}) } // categoryScopeIDs 选中分类时包含自身与所有直接子分类(两级树) func categoryScopeIDs(tenantID, categoryID uint) []uint { ids := []uint{categoryID} var children []model.Category model.DB.Where("tenant_id = ? AND parent_id = ?", tenantID, categoryID).Find(&children) for _, ch := range children { ids = append(ids, ch.ID) } return ids } type EntryListItem struct { model.KnowledgeEntry CategoryName string `json:"category_name"` } func (h *KnowledgeHandler) ListEntries(c *gin.Context) { tenantID := middleware.GetTenantID(c) page, pageSize := middleware.GetPageParams(c) categoryID := c.Query("category_id") search := c.Query("search") status := c.Query("status") sortBy := c.Query("sort") // usage | updated(default) var entries []model.KnowledgeEntry var total int64 query := model.DB.Where("tenant_id = ?", tenantID) if categoryID != "" { if id64, err := strconv.ParseUint(categoryID, 10, 64); err == nil && id64 > 0 { ids := categoryScopeIDs(tenantID, uint(id64)) query = query.Where("category_id IN ?", ids) } else { query = query.Where("category_id = ?", categoryID) } } if search != "" { query = query.Where("title LIKE ? OR content LIKE ?", "%"+search+"%", "%"+search+"%") } if status != "" { query = query.Where("status = ?", status) } query.Model(&model.KnowledgeEntry{}).Count(&total) order := "updated_at desc" if sortBy == "usage" { order = "usage_count desc, updated_at desc" } else if sortBy == "title" { order = "title asc" } query.Order(order).Offset((page - 1) * pageSize).Limit(pageSize).Find(&entries) catIDs := make([]uint, 0, len(entries)) for _, e := range entries { catIDs = append(catIDs, e.CategoryID) } catMap := map[uint]string{} if len(catIDs) > 0 { seen := map[uint]struct{}{} unique := make([]uint, 0) for _, id := range catIDs { if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} unique = append(unique, id) } var cats []model.Category model.DB.Where("id IN ?", unique).Find(&cats) for _, cat := range cats { catMap[cat.ID] = cat.Name } } items := make([]EntryListItem, 0, len(entries)) for _, e := range entries { items = append(items, EntryListItem{ KnowledgeEntry: e, CategoryName: catMap[e.CategoryID], }) } middleware.JSONList(c, items, total, page, pageSize) } func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { if !requireKnowledgeManager(c) { return } var entry model.KnowledgeEntry if err := c.ShouldBindJSON(&entry); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } entry.TenantID = middleware.GetTenantID(c) available, err := hasKnowledgeCapacity(entry.TenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "校验知识库容量失败"}) return } if !available { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "当前套餐知识库容量已达上限"}) return } var category model.Category if err := model.DB.Where("id = ? AND tenant_id = ?", entry.CategoryID, entry.TenantID).First(&category).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "知识分类不存在"}) return } if err := model.DB.Create(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } middleware.JSON(c, entry) } func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) { if !requireKnowledgeManager(c) { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") var entry model.KnowledgeEntry if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&entry).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"}) return } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } allowed := map[string]bool{"category_id": true, "title": true, "content": true, "status": true} for key := range updates { if !allowed[key] { delete(updates, key) } } if categoryID, exists := updates["category_id"]; exists { var category model.Category if err := model.DB.Where("id = ? AND tenant_id = ?", categoryID, tenantID).First(&category).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "知识分类不存在"}) return } } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } if err := model.DB.Model(&entry).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } model.DB.First(&entry, entry.ID) middleware.JSON(c, entry) } func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) { if !requireKnowledgeManager(c) { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") result := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&model.KnowledgeEntry{}) if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"}) return } middleware.JSON(c, gin.H{"message": "已删除"}) }