Files
xinmi/internal/handler/tag.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

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.