Files
Enterprise-Knowledge-Base/internal/handler/knowledge.go
T
wizardchen 4158773849 feat: add organization management features
- Introduced a new API for organization management, including creation, updating, and deletion of organizations.
- Implemented organization sharing functionality for knowledge bases, allowing users to share knowledge bases with specific organizations and set permissions.
- Added UI components for organization selection and sharing dialogs.
- Enhanced existing components to support organization-related features, including shared knowledge base handling and organization role management.

This update enhances collaboration capabilities within the application by allowing users to manage and share knowledge bases across different organizations.
2026-02-09 11:40:25 +08:00

1049 lines
37 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"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"
"github.com/gin-gonic/gin"
)
// KnowledgeHandler processes HTTP requests related to knowledge resources
type KnowledgeHandler struct {
kgService interfaces.KnowledgeService
kbService interfaces.KnowledgeBaseService
kbShareService interfaces.KBShareService
}
// NewKnowledgeHandler creates a new knowledge handler instance
func NewKnowledgeHandler(
kgService interfaces.KnowledgeService,
kbService interfaces.KnowledgeBaseService,
kbShareService interfaces.KBShareService,
) *KnowledgeHandler {
return &KnowledgeHandler{
kgService: kgService,
kbService: kbService,
kbShareService: kbShareService,
}
}
// validateKnowledgeBaseAccess validates access permissions to a knowledge base
// Returns the knowledge base, the knowledge base ID, effective tenant ID, permission level, and any errors encountered
// For owned KBs, effectiveTenantID is the caller's tenant ID
// For shared KBs, effectiveTenantID is the source tenant ID (owner's tenant)
func (h *KnowledgeHandler) validateKnowledgeBaseAccess(c *gin.Context) (*types.KnowledgeBase, string, uint64, types.OrgMemberRole, error) {
ctx := c.Request.Context()
// Get tenant ID from context
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Failed to get tenant ID")
return nil, "", 0, "", errors.NewUnauthorizedError("Unauthorized")
}
// Get user ID from context (needed for shared KB permission check)
userID, userExists := c.Get(types.UserIDContextKey.String())
// Get knowledge base ID from URL path parameter
kbID := secutils.SanitizeForLog(c.Param("id"))
if kbID == "" {
logger.Error(ctx, "Knowledge base ID is empty")
return nil, "", 0, "", errors.NewBadRequestError("Knowledge base ID cannot be empty")
}
// Get knowledge base details
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
return nil, kbID, 0, "", errors.NewInternalServerError(err.Error())
}
// Check 1: Verify tenant ownership (owner has full access)
if kb.TenantID == tenantID {
return kb, kbID, tenantID, types.OrgRoleAdmin, nil
}
// Check 2: If not owner, check organization shared access
if userExists && h.kbShareService != nil {
// Check if user has shared access through organization
permission, isShared, permErr := h.kbShareService.CheckUserKBPermission(ctx, kbID, userID.(string))
if permErr == nil && isShared {
// User has shared access, get the source tenant ID for queries
sourceTenantID, srcErr := h.kbShareService.GetKBSourceTenant(ctx, kbID)
if srcErr == nil {
logger.Infof(ctx, "User %s accessing shared KB %s with permission %s, source tenant: %d",
userID.(string), kbID, permission, sourceTenantID)
return kb, kbID, sourceTenantID, permission, nil
}
}
}
// No permission: not owner and no shared access
logger.Warnf(
ctx,
"Permission denied to access this knowledge base, tenant ID mismatch, "+
"requested tenant ID: %d, knowledge base tenant ID: %d",
tenantID,
kb.TenantID,
)
return nil, kbID, 0, "", errors.NewForbiddenError("Permission denied to access this knowledge base")
}
// validateKnowledgeBaseAccessWithKBID validates access to the given knowledge base ID (e.g. from query or body).
// Returns the knowledge base, kbID, effective tenant ID, permission, and error.
func (h *KnowledgeHandler) validateKnowledgeBaseAccessWithKBID(c *gin.Context, kbID string) (*types.KnowledgeBase, string, uint64, types.OrgMemberRole, error) {
ctx := c.Request.Context()
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
logger.Error(ctx, "Failed to get tenant ID")
return nil, "", 0, "", errors.NewUnauthorizedError("Unauthorized")
}
userID, userExists := c.Get(types.UserIDContextKey.String())
kbID = secutils.SanitizeForLog(kbID)
if kbID == "" {
return nil, "", 0, "", errors.NewBadRequestError("Knowledge base ID cannot be empty")
}
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
return nil, kbID, 0, "", errors.NewInternalServerError(err.Error())
}
if kb.TenantID == tenantID {
return kb, kbID, tenantID, types.OrgRoleAdmin, nil
}
if userExists && h.kbShareService != nil {
permission, isShared, permErr := h.kbShareService.CheckUserKBPermission(ctx, kbID, userID.(string))
if permErr == nil && isShared {
sourceTenantID, srcErr := h.kbShareService.GetKBSourceTenant(ctx, kbID)
if srcErr == nil {
logger.Infof(ctx, "User %s accessing shared KB %s with permission %s, source tenant: %d",
userID.(string), kbID, permission, sourceTenantID)
return kb, kbID, sourceTenantID, permission, nil
}
}
}
logger.Warnf(ctx, "Permission denied to access KB %s, tenant ID: %d, KB tenant: %d", kbID, tenantID, kb.TenantID)
return nil, kbID, 0, "", errors.NewForbiddenError("Permission denied to access this knowledge base")
}
// resolveKnowledgeAndValidateKBAccess resolves knowledge by ID and validates KB access (owner or shared with required permission).
// Returns the knowledge, context with effectiveTenantID set for downstream service calls, and error.
func (h *KnowledgeHandler) resolveKnowledgeAndValidateKBAccess(c *gin.Context, knowledgeID string, requiredPermission types.OrgMemberRole) (*types.Knowledge, context.Context, error) {
ctx := c.Request.Context()
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
return nil, ctx, errors.NewUnauthorizedError("Unauthorized")
}
userID, userExists := c.Get(types.UserIDContextKey.String())
knowledge, err := h.kgService.GetKnowledgeByIDOnly(ctx, knowledgeID)
if err != nil {
return nil, ctx, errors.NewNotFoundError("Knowledge not found")
}
// Owner: knowledge belongs to caller's tenant
if knowledge.TenantID == tenantID {
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, tenantID), nil
}
// Shared KB: check organization permission
if !userExists || h.kbShareService == nil {
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
}
permission, isShared, permErr := h.kbShareService.CheckUserKBPermission(ctx, knowledge.KnowledgeBaseID, userID.(string))
if permErr != nil || !isShared {
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
}
if !permission.HasPermission(requiredPermission) {
return nil, ctx, errors.NewForbiddenError("Insufficient permission for this operation")
}
effectiveTenantID := knowledge.TenantID
return knowledge, context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID), nil
}
// handleDuplicateKnowledgeError handles cases where duplicate knowledge is detected
// Returns true if the error was a duplicate error and was handled, false otherwise
func (h *KnowledgeHandler) handleDuplicateKnowledgeError(c *gin.Context,
err error, knowledge *types.Knowledge, duplicateType string,
) bool {
if dupErr, ok := err.(*types.DuplicateKnowledgeError); ok {
ctx := c.Request.Context()
logger.Warnf(ctx, "Detected duplicate %s: %s", duplicateType, secutils.SanitizeForLog(dupErr.Error()))
c.JSON(http.StatusConflict, gin.H{
"success": false,
"message": dupErr.Error(),
"data": knowledge, // knowledge contains the existing document
"code": fmt.Sprintf("duplicate_%s", duplicateType),
})
return true
}
return false
}
// CreateKnowledgeFromFile godoc
// @Summary 从文件创建知识
// @Description 上传文件并创建知识条目
// @Tags 知识管理
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "知识库ID"
// @Param file formData file true "上传的文件"
// @Param fileName formData string false "自定义文件名"
// @Param metadata formData string false "元数据JSON"
// @Param enable_multimodel formData bool false "启用多模态处理"
// @Success 200 {object} map[string]interface{} "创建的知识"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 409 {object} map[string]interface{} "文件重复"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/knowledge/file [post]
func (h *KnowledgeHandler) CreateKnowledgeFromFile(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start creating knowledge from file")
// Validate access to the knowledge base (only owner or admin/editor can create)
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
if err != nil {
c.Error(err)
return
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
// Check write permission
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
c.Error(errors.NewForbiddenError("No permission to create knowledge"))
return
}
// Get the uploaded file
file, err := c.FormFile("file")
if err != nil {
logger.Error(ctx, "File upload failed", err)
c.Error(errors.NewBadRequestError("File upload failed").WithDetails(err.Error()))
return
}
// Validate file size (configurable via MAX_FILE_SIZE_MB)
maxSize := secutils.GetMaxFileSize()
if file.Size > maxSize {
logger.Error(ctx, "File size too large")
c.Error(errors.NewBadRequestError(fmt.Sprintf("文件大小不能超过%dMB", secutils.GetMaxFileSizeMB())))
return
}
// Get custom filename if provided (for folder uploads with path)
customFileName := c.PostForm("fileName")
customFileName = secutils.SanitizeForLog(customFileName)
displayFileName := file.Filename
displayFileName = secutils.SanitizeForLog(displayFileName)
if customFileName != "" {
displayFileName = customFileName
logger.Infof(ctx, "Using custom filename: %s (original: %s)", customFileName, displayFileName)
}
logger.Infof(ctx, "File upload successful, filename: %s, size: %.2f KB", displayFileName, float64(file.Size)/1024)
logger.Infof(ctx, "Creating knowledge, knowledge base ID: %s, filename: %s", kbID, displayFileName)
// Parse metadata if provided
var metadata map[string]string
metadataStr := c.PostForm("metadata")
if metadataStr != "" {
if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
logger.Error(ctx, "Failed to parse metadata", err)
c.Error(errors.NewBadRequestError("Invalid metadata format").WithDetails(err.Error()))
return
}
logger.Infof(ctx, "Received file metadata: %s", secutils.SanitizeForLog(fmt.Sprintf("%v", metadata)))
}
enableMultimodelForm := c.PostForm("enable_multimodel")
var enableMultimodel *bool
if enableMultimodelForm != "" {
parseBool, err := strconv.ParseBool(enableMultimodelForm)
if err != nil {
logger.Error(ctx, "Failed to parse enable_multimodel", err)
c.Error(errors.NewBadRequestError("Invalid enable_multimodel format").WithDetails(err.Error()))
return
}
enableMultimodel = &parseBool
}
// 获取分类ID(如果提供),用于知识分类管理
tagID := c.PostForm("tag_id")
// 过滤特殊值,空字符串或 "__untagged__" 表示未分类
if tagID == "__untagged__" || tagID == "" {
tagID = ""
}
// Create knowledge entry from the file
knowledge, err := h.kgService.CreateKnowledgeFromFile(ctx, kbID, file, metadata, enableMultimodel, customFileName, tagID)
// Check for duplicate knowledge error
if err != nil {
if h.handleDuplicateKnowledgeError(c, err, knowledge, "file") {
return
}
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(
ctx,
"Knowledge created successfully, ID: %s, title: %s",
secutils.SanitizeForLog(knowledge.ID),
secutils.SanitizeForLog(knowledge.Title),
)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": knowledge,
})
}
// CreateKnowledgeFromURL godoc
// @Summary 从URL创建知识
// @Description 从指定URL抓取内容并创建知识条目
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body object{url=string,enable_multimodel=bool,title=string,tag_id=string} true "URL请求"
// @Success 201 {object} map[string]interface{} "创建的知识"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 409 {object} map[string]interface{} "URL重复"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/knowledge/url [post]
func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start creating knowledge from URL")
// Validate access to the knowledge base (only owner or admin/editor can create)
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
if err != nil {
c.Error(err)
return
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
// Check write permission
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
c.Error(errors.NewForbiddenError("No permission to create knowledge"))
return
}
// Parse URL from request body
var req struct {
URL string `json:"url" binding:"required"`
EnableMultimodel *bool `json:"enable_multimodel"`
Title string `json:"title"`
TagID string `json:"tag_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse URL request", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
logger.Infof(ctx, "Received URL request: %s", secutils.SanitizeForLog(req.URL))
logger.Infof(
ctx,
"Creating knowledge from URL, knowledge base ID: %s, URL: %s",
secutils.SanitizeForLog(kbID),
secutils.SanitizeForLog(req.URL),
)
// Create knowledge entry from the URL
knowledge, err := h.kgService.CreateKnowledgeFromURL(ctx, kbID, req.URL, req.EnableMultimodel, req.Title, req.TagID)
// Check for duplicate knowledge error
if err != nil {
if h.handleDuplicateKnowledgeError(c, err, knowledge, "url") {
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(
ctx,
"Knowledge created successfully from URL, ID: %s, title: %s",
secutils.SanitizeForLog(knowledge.ID),
secutils.SanitizeForLog(knowledge.Title),
)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": knowledge,
})
}
// CreateManualKnowledge godoc
// @Summary 手工创建知识
// @Description 手工录入Markdown格式的知识内容
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body types.ManualKnowledgePayload true "手工知识内容"
// @Success 200 {object} map[string]interface{} "创建的知识"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/knowledge/manual [post]
func (h *KnowledgeHandler) CreateManualKnowledge(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start creating manual knowledge")
// Validate access to the knowledge base (only owner or admin/editor can create)
_, kbID, effectiveTenantID, permission, err := h.validateKnowledgeBaseAccess(c)
if err != nil {
c.Error(err)
return
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
// Check write permission
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
c.Error(errors.NewForbiddenError("No permission to create knowledge"))
return
}
var req types.ManualKnowledgePayload
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse manual knowledge request", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
knowledge, err := h.kgService.CreateKnowledgeFromManual(ctx, kbID, &req)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"kb_id": kbID,
})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Manual knowledge created successfully, knowledge ID: %s",
secutils.SanitizeForLog(knowledge.ID))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": knowledge,
})
}
// GetKnowledge godoc
// @Summary 获取知识详情
// @Description 根据ID获取知识条目详情
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识ID"
// @Success 200 {object} map[string]interface{} "知识详情"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "知识不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/{id} [get]
func (h *KnowledgeHandler) GetKnowledge(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving knowledge")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
// Resolve knowledge and validate KB access (at least viewer)
knowledge, _, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
if err != nil {
c.Error(err)
return
}
logger.Infof(ctx, "Knowledge retrieved successfully, ID: %s, title: %s",
secutils.SanitizeForLog(knowledge.ID), secutils.SanitizeForLog(knowledge.Title))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": knowledge,
})
}
// ListKnowledge 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 tag_id query string false "标签ID筛选"
// @Param keyword query string false "关键词搜索"
// @Param file_type query string false "文件类型筛选"
// @Success 200 {object} map[string]interface{} "知识列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge-bases/{id}/knowledge [get]
func (h *KnowledgeHandler) ListKnowledge(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving knowledge list")
// Validate access to the knowledge base (read access - any permission level)
_, kbID, effectiveTenantID, _, err := h.validateKnowledgeBaseAccess(c)
if err != nil {
c.Error(err)
return
}
// Update context with effective tenant ID for shared KB access
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
// Parse pagination parameters from query string
var pagination types.Pagination
if err := c.ShouldBindQuery(&pagination); err != nil {
logger.Error(ctx, "Failed to parse pagination parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
tagID := c.Query("tag_id")
keyword := c.Query("keyword")
fileType := c.Query("file_type")
logger.Infof(
ctx,
"Retrieving knowledge list under knowledge base, knowledge base ID: %s, tag_id: %s, keyword: %s, file_type: %s, page: %d, page size: %d, effectiveTenantID: %d",
secutils.SanitizeForLog(kbID),
secutils.SanitizeForLog(tagID),
secutils.SanitizeForLog(keyword),
secutils.SanitizeForLog(fileType),
pagination.Page,
pagination.PageSize,
effectiveTenantID,
)
// Retrieve paginated knowledge entries
result, err := h.kgService.ListPagedKnowledgeByKnowledgeBaseID(ctx, kbID, &pagination, tagID, keyword, fileType)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(
ctx,
"Knowledge list retrieved successfully, knowledge base ID: %s, total: %d",
secutils.SanitizeForLog(kbID),
result.Total,
)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result.Data,
"total": result.Total,
"page": result.Page,
"page_size": result.PageSize,
})
}
// DeleteKnowledge godoc
// @Summary 删除知识
// @Description 根据ID删除知识条目
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/{id} [delete]
func (h *KnowledgeHandler) DeleteKnowledge(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start deleting knowledge")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
if err != nil {
c.Error(err)
return
}
logger.Infof(ctx, "Deleting knowledge, ID: %s", secutils.SanitizeForLog(id))
err = h.kgService.DeleteKnowledge(effCtx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Knowledge deleted successfully, ID: %s", secutils.SanitizeForLog(id))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Deleted successfully",
})
}
// DownloadKnowledgeFile godoc
// @Summary 下载知识文件
// @Description 下载知识条目关联的原始文件
// @Tags 知识管理
// @Accept json
// @Produce application/octet-stream
// @Param id path string true "知识ID"
// @Success 200 {file} file "文件内容"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/{id}/download [get]
func (h *KnowledgeHandler) DownloadKnowledgeFile(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start downloading knowledge file")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleViewer)
if err != nil {
c.Error(err)
return
}
logger.Infof(ctx, "Retrieving knowledge file, ID: %s", secutils.SanitizeForLog(id))
file, filename, err := h.kgService.GetKnowledgeFile(effCtx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve file").WithDetails(err.Error()))
return
}
defer file.Close()
logger.Infof(
ctx,
"Knowledge file retrieved successfully, ID: %s, filename: %s",
secutils.SanitizeForLog(id),
secutils.SanitizeForLog(filename),
)
// Set response headers for file download
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Header("Content-Type", "application/octet-stream")
c.Header("Expires", "0")
c.Header("Cache-Control", "must-revalidate")
c.Header("Pragma", "public")
// Stream file content to response
c.Stream(func(w io.Writer) bool {
if _, err := io.Copy(w, file); err != nil {
logger.Errorf(ctx, "Failed to send file: %v", err)
return false
}
logger.Debug(ctx, "File sending completed")
return false
})
}
// GetKnowledgeBatchRequest defines parameters for batch knowledge retrieval
type GetKnowledgeBatchRequest struct {
IDs []string `form:"ids" binding:"required"` // List of knowledge IDs
KBID string `form:"kb_id"` // Optional: scope to this KB (validates access and uses effective tenant for shared KB)
}
// GetKnowledgeBatch godoc
// @Summary 批量获取知识
// @Description 根据ID列表批量获取知识条目。可选 kb_id:指定时按该知识库校验权限并用于共享知识库的租户解析
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param ids query []string true "知识ID列表"
// @Param kb_id query string false "可选,知识库ID(用于共享知识库时指定范围)"
// @Success 200 {object} map[string]interface{} "知识列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/batch [get]
func (h *KnowledgeHandler) GetKnowledgeBatch(c *gin.Context) {
ctx := c.Request.Context()
tenantID, ok := c.Get(types.TenantIDContextKey.String())
if !ok {
logger.Error(ctx, "Failed to get tenant ID")
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
effectiveTenantID := tenantID.(uint64)
var req GetKnowledgeBatchRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError("Invalid request parameters").WithDetails(err.Error()))
return
}
var knowledges []*types.Knowledge
var err error
// Optional kb_id: validate KB access and use effective tenant for shared KB
if kbID := secutils.SanitizeForLog(req.KBID); kbID != "" {
_, _, effID, _, err := h.validateKnowledgeBaseAccessWithKBID(c, kbID)
if err != nil {
c.Error(err)
return
}
effectiveTenantID = effID
ctx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
logger.Infof(ctx, "Batch retrieving knowledge with kb_id, effective tenant ID: %d, IDs count: %d",
effectiveTenantID, len(req.IDs))
knowledges, err = h.kgService.GetKnowledgeBatch(ctx, effectiveTenantID, req.IDs)
} else {
// No kb_id provided: use GetKnowledgeBatchWithSharedAccess to support cross-tenant shared files
// This is needed when page is refreshed and frontend doesn't have kb_id mapping
logger.Infof(ctx, "Batch retrieving knowledge without kb_id (using shared access), tenant ID: %d, IDs count: %d",
effectiveTenantID, len(req.IDs))
knowledges, err = h.kgService.GetKnowledgeBatchWithSharedAccess(ctx, effectiveTenantID, req.IDs)
}
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve knowledge list").WithDetails(err.Error()))
return
}
logger.Infof(ctx, "Batch knowledge retrieval successful, requested count: %d, returned count: %d",
len(req.IDs), len(knowledges))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": knowledges,
})
}
// UpdateKnowledge godoc
// @Summary 更新知识
// @Description 更新知识条目信息
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识ID"
// @Param request body types.Knowledge true "知识信息"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/{id} [put]
func (h *KnowledgeHandler) UpdateKnowledge(c *gin.Context) {
ctx := c.Request.Context()
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
if err != nil {
c.Error(err)
return
}
var knowledge types.Knowledge
if err := c.ShouldBindJSON(&knowledge); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
knowledge.ID = id
if err := h.kgService.UpdateKnowledge(effCtx, &knowledge); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Knowledge updated successfully, knowledge ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Knowledge chunk updated successfully",
})
}
// UpdateManualKnowledge godoc
// @Summary 更新手工知识
// @Description 更新手工录入的Markdown知识内容
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识ID"
// @Param request body types.ManualKnowledgePayload true "手工知识内容"
// @Success 200 {object} map[string]interface{} "更新后的知识"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/manual/{id} [put]
func (h *KnowledgeHandler) UpdateManualKnowledge(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating manual knowledge")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
if err != nil {
c.Error(err)
return
}
var req types.ManualKnowledgePayload
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse manual knowledge update request", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
knowledge, err := h.kgService.UpdateManualKnowledge(effCtx, id, &req)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_id": id,
})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Manual knowledge updated successfully, knowledge ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": knowledge,
})
}
type knowledgeTagBatchRequest struct {
Updates map[string]*string `json:"updates" binding:"required,min=1"`
KBID string `json:"kb_id"` // Optional: scope to this KB (validates editor access and uses effective tenant for shared KB)
}
// UpdateKnowledgeTagBatch godoc
// @Summary 批量更新知识标签
// @Description 批量更新知识条目的标签。可选 kb_id:指定时按该知识库校验编辑权限并用于共享知识库的租户解析
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param request body object true "标签更新请求(updates 必填,kb_id 可选)"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/tags [put]
func (h *KnowledgeHandler) UpdateKnowledgeTagBatch(c *gin.Context) {
ctx := c.Request.Context()
// Ensure tenant ID is in context (service reads it; may be missing if request context was not set by auth)
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
var req knowledgeTagBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse knowledge tag batch request", err)
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
return
}
// Resolve effective tenant: explicit kb_id, or infer from first knowledge ID (for shared KB when frontend doesn't send kb_id)
if kbID := secutils.SanitizeForLog(req.KBID); kbID != "" {
_, _, effID, permission, err := h.validateKnowledgeBaseAccessWithKBID(c, kbID)
if err != nil {
c.Error(err)
return
}
if permission != types.OrgRoleAdmin && permission != types.OrgRoleEditor {
c.Error(errors.NewForbiddenError("No permission to update knowledge tags"))
return
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, effID)
} else if len(req.Updates) > 0 {
// No kb_id: infer from first knowledge ID so shared-KB updates work without client sending kb_id
var firstKnowledgeID string
for id := range req.Updates {
firstKnowledgeID = id
break
}
if firstKnowledgeID != "" {
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, firstKnowledgeID, types.OrgRoleEditor)
if err != nil {
c.Error(err)
return
}
ctx = effCtx
}
}
if err := h.kgService.UpdateKnowledgeTagBatch(ctx, req.Updates); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}
// UpdateImageInfo godoc
// @Summary 更新图像信息
// @Description 更新知识分块的图像信息
// @Tags 知识管理
// @Accept json
// @Produce json
// @Param id path string true "知识ID"
// @Param chunk_id path string true "分块ID"
// @Param request body object{image_info=string} true "图像信息"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/image/{id}/{chunk_id} [put]
func (h *KnowledgeHandler) UpdateImageInfo(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating image info")
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Knowledge ID is empty")
c.Error(errors.NewBadRequestError("Knowledge ID cannot be empty"))
return
}
chunkID := secutils.SanitizeForLog(c.Param("chunk_id"))
if chunkID == "" {
logger.Error(ctx, "Chunk ID is empty")
c.Error(errors.NewBadRequestError("Chunk ID cannot be empty"))
return
}
_, effCtx, err := h.resolveKnowledgeAndValidateKBAccess(c, id, types.OrgRoleEditor)
if err != nil {
c.Error(err)
return
}
var request struct {
ImageInfo string `json:"image_info"`
}
if err := c.ShouldBindJSON(&request); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
logger.Infof(ctx, "Updating knowledge chunk, knowledge ID: %s, chunk ID: %s", id, chunkID)
err = h.kgService.UpdateImageInfo(effCtx, id, chunkID, secutils.SanitizeForLog(request.ImageInfo))
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Knowledge chunk updated successfully, knowledge ID: %s, chunk ID: %s", id, chunkID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Knowledge chunk image updated successfully",
})
}
// SearchKnowledge godoc
// @Summary Search knowledge
// @Description Search knowledge files by keyword across all knowledge bases
// @Tags Knowledge
// @Accept json
// @Produce json
// @Param keyword query string false "Keyword to search"
// @Param offset query int false "Offset for pagination"
// @Param limit query int false "Limit for pagination (default 20)"
// @Param file_types query string false "Comma-separated file extensions to filter (e.g., csv,xlsx)"
// @Success 200 {object} map[string]interface{} "Search results"
// @Failure 400 {object} errors.AppError "Invalid request"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /knowledge/search [get]
func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) {
ctx := c.Request.Context()
if userID, ok := c.Get(types.UserIDContextKey.String()); ok {
ctx = context.WithValue(ctx, types.UserIDContextKey, userID)
}
keyword := c.Query("keyword")
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
// Parse file_types parameter (comma-separated)
var fileTypes []string
if fileTypesStr := c.Query("file_types"); fileTypesStr != "" {
for _, ft := range strings.Split(fileTypesStr, ",") {
ft = strings.TrimSpace(ft)
if ft != "" {
fileTypes = append(fileTypes, ft)
}
}
}
// Retrieve knowledge entries (own + shared KBs; empty keyword returns recent files)
knowledges, hasMore, err := h.kgService.SearchKnowledge(ctx, keyword, offset, limit, fileTypes)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to search knowledge").WithDetails(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": knowledges,
"has_more": hasMore,
})
}