269 lines
8.6 KiB
Go
269 lines
8.6 KiB
Go
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.
|