package handler import ( "context" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" secutils "github.com/Tencent/WeKnora/internal/utils" ) // TagHandler handles knowledge base tag operations. // // All KB-access checks (own / org-shared / via shared agent) are now // performed by the route-level g.KBAccessRead / g.KBAccessWrite // guards in router.go — the guard rewrites c.Request.Context() to // carry the effective tenant ID, so handlers below just use // c.Request.Context() the way they always did. type TagHandler struct { tagService interfaces.KnowledgeTagService tagRepo interfaces.KnowledgeTagRepository chunkRepo interfaces.ChunkRepository } // DeleteTagRequest represents the request body for deleting a tag type DeleteTagRequest struct { ExcludeIDs []int64 `json:"exclude_ids"` // Chunk seq_ids to exclude from deletion } // NewTagHandler creates a new TagHandler. func NewTagHandler( tagService interfaces.KnowledgeTagService, tagRepo interfaces.KnowledgeTagRepository, chunkRepo interfaces.ChunkRepository, ) *TagHandler { return &TagHandler{tagService: tagService, tagRepo: tagRepo, chunkRepo: chunkRepo} } // resolveTagID resolves tag_id parameter which can be either UUID or seq_id (integer). // Uses tenant from c's context — which the route-level KB-access guard // has already rewritten to the effective tenant for shared KBs. func (h *TagHandler) resolveTagID(c *gin.Context) (string, error) { return h.resolveTagIDWithCtx(c, c.Request.Context()) } // resolveTagIDWithCtx resolves tag_id using the given context for tenant. func (h *TagHandler) resolveTagIDWithCtx(c *gin.Context, ctx context.Context) (string, error) { tagIDParam := secutils.SanitizeForLog(c.Param("tag_id")) if seqID, err := strconv.ParseInt(tagIDParam, 10, 64); err == nil { tenantID := types.MustTenantIDFromContext(ctx) tag, err := h.tagRepo.GetBySeqID(ctx, tenantID, seqID) if err != nil { return "", errors.NewNotFoundError("标签不存在") } return tag.ID, nil } return tagIDParam, nil } // getChunksBySeqIDs retrieves chunks by their seq_ids. func (h *TagHandler) getChunksBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.Chunk, error) { return h.chunkRepo.ListChunksBySeqID(ctx, tenantID, seqIDs) } // ListTags godoc // @Summary 获取标签列表 // @Description 获取知识库下的所有标签及统计信息 // @Tags 标签管理 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Param keyword query string false "关键词搜索" // @Success 200 {object} map[string]interface{} "标签列表" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/tags [get] func (h *TagHandler) ListTags(c *gin.Context) { ctx := c.Request.Context() kbID := secutils.SanitizeForLog(c.Param("id")) var page types.Pagination if err := c.ShouldBindQuery(&page); err != nil { logger.Error(ctx, "Failed to bind pagination query", err) c.Error(errors.NewBadRequestError("分页参数不合法").WithDetails(err.Error())) return } keyword := secutils.SanitizeForLog(c.Query("keyword")) tags, err := h.tagService.ListTags(ctx, kbID, &page, keyword) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": tags, }) } type createTagRequest struct { Name string `json:"name" binding:"required"` Color string `json:"color"` SortOrder int `json:"sort_order"` } // CreateTag godoc // @Summary 创建标签 // @Description 在知识库下创建新标签 // @Tags 标签管理 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param request body object{name=string,color=string,sort_order=int} true "标签信息" // @Success 200 {object} map[string]interface{} "创建的标签" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/tags [post] func (h *TagHandler) CreateTag(c *gin.Context) { ctx := c.Request.Context() kbID := secutils.SanitizeForLog(c.Param("id")) var req createTagRequest if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to bind create tag payload", err) c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error())) return } tag, err := h.tagService.CreateTag(ctx, kbID, secutils.SanitizeForLog(req.Name), secutils.SanitizeForLog(req.Color), req.SortOrder) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "kb_id": kbID, }) c.Error(err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": tag, }) } type updateTagRequest struct { Name *string `json:"name"` Color *string `json:"color"` SortOrder *int `json:"sort_order"` } // UpdateTag godoc // @Summary 更新标签 // @Description 更新标签信息 // @Tags 标签管理 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param tag_id path string true "标签ID (UUID或seq_id)" // @Param request body object true "标签更新信息" // @Success 200 {object} map[string]interface{} "更新后的标签" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/tags/{tag_id} [put] func (h *TagHandler) UpdateTag(c *gin.Context) { ctx := c.Request.Context() tagID, err := h.resolveTagID(c) if err != nil { c.Error(err) return } var req updateTagRequest if err := c.ShouldBindJSON(&req); err != nil { logger.Error(ctx, "Failed to bind update tag payload", err) c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error())) return } tag, err := h.tagService.UpdateTag(ctx, tagID, req.Name, req.Color, req.SortOrder) if err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "tag_id": tagID, }) c.Error(err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": tag, }) } // DeleteTag godoc // @Summary 删除标签 // @Description 删除标签,可使用force=true强制删除被引用的标签,content_only=true仅删除标签下的内容而保留标签本身 // @Tags 标签管理 // @Accept json // @Produce json // @Param id path string true "知识库ID" // @Param tag_id path string true "标签ID (UUID或seq_id)" // @Param force query bool false "强制删除" // @Param content_only query bool false "仅删除内容,保留标签" // @Param body body DeleteTagRequest false "删除选项" // @Success 200 {object} map[string]interface{} "删除成功" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-bases/{id}/tags/{tag_id} [delete] func (h *TagHandler) DeleteTag(c *gin.Context) { ctx := c.Request.Context() tagID, err := h.resolveTagID(c) if err != nil { c.Error(err) return } force := c.Query("force") == "true" contentOnly := c.Query("content_only") == "true" var req DeleteTagRequest _ = c.ShouldBindJSON(&req) var excludeUUIDs []string if len(req.ExcludeIDs) > 0 { tenantID := types.MustTenantIDFromContext(ctx) chunks, err := h.getChunksBySeqIDs(ctx, tenantID, req.ExcludeIDs) if err != nil { logger.Warnf(ctx, "Failed to resolve exclude_ids: %v", err) } else { excludeUUIDs = make([]string, len(chunks)) for i, chunk := range chunks { excludeUUIDs[i] = chunk.ID } } } if err := h.tagService.DeleteTag(ctx, tagID, force, contentOnly, excludeUUIDs); err != nil { logger.ErrorWithFields(ctx, err, map[string]interface{}{ "tag_id": tagID, }) c.Error(err) return } c.JSON(http.StatusOK, gin.H{ "success": true, }) } // NOTE: TagHandler currently exposes CRUD for tags and statistics. // Knowledge / Chunk tagging is handled via dedicated knowledge and FAQ APIs.