928f5abe3f
Before this change, the `organization_members` table keyed on user_id — joining an org as a Viewer in tenant T meant only that one user got visibility on shared KBs/agents, while the rest of T (including its Owner) saw nothing. The mismatch surfaced as confusing UX bugs: "my colleague shared this with us, why can't I see it?" This change replaces `organization_members` with `organization_tenant_members`. Membership and Org-RBAC role now key on (org_id, tenant_id); `representative_user_id` stays as a display-only label of the user who first brought the tenant in. Permission resolution gains a 3-D cap so tenant-RBAC stays honest under cross-tenant sharing: effective = min(share.Permission, tenant_org_role, tenant_role_cap) where tenant_role_cap pins tenant Viewers to OrgRoleViewer regardless of the org-level grant. A Viewer in their own tenant cannot edit a shared KB even if the org granted Editor — preserves the "Viewer = read-only everywhere" promise. Migration 000045 backfills `organization_tenant_members` from the old table by collapsing each (org, tenant) group to its max role, then renames the old table to `organization_members_pre_plan3` for safe rollback. Sqlite mirror (Lite mode) is fresh-bootstrap: the init schema replaces the old table directly. Routes: member-management URLs change from `:user_id` to `:tenant_id`. Org create / join / leave now require Admin+ in the caller's tenant — joining an Org affects everyone in T, so the decision can't sit with a Viewer. Frontend: OrganizationSettingsModal + store update to address members by tenant_id.
672 lines
24 KiB
Go
672 lines
24 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
stderrors "errors"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||
"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"
|
||
)
|
||
|
||
// FAQHandler handles FAQ knowledge base operations.
|
||
type FAQHandler struct {
|
||
knowledgeService interfaces.KnowledgeService
|
||
kbService interfaces.KnowledgeBaseService
|
||
kbShareService interfaces.KBShareService
|
||
agentShareService interfaces.AgentShareService
|
||
}
|
||
|
||
// NewFAQHandler creates a new FAQ handler
|
||
func NewFAQHandler(
|
||
knowledgeService interfaces.KnowledgeService,
|
||
kbService interfaces.KnowledgeBaseService,
|
||
kbShareService interfaces.KBShareService,
|
||
agentShareService interfaces.AgentShareService,
|
||
) *FAQHandler {
|
||
return &FAQHandler{
|
||
knowledgeService: knowledgeService,
|
||
kbService: kbService,
|
||
kbShareService: kbShareService,
|
||
agentShareService: agentShareService,
|
||
}
|
||
}
|
||
|
||
// effectiveCtxForKB validates KB access (owner, shared, or via shared agent when requiredPermission is Viewer) and returns context with effectiveTenantID.
|
||
func (h *FAQHandler) effectiveCtxForKB(c *gin.Context, kbID string, requiredPermission types.OrgMemberRole) (context.Context, error) {
|
||
ctx := c.Request.Context()
|
||
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
||
if tenantID == 0 {
|
||
return nil, errors.NewUnauthorizedError("Unauthorized")
|
||
}
|
||
userID, userExists := c.Get(types.UserIDContextKey.String())
|
||
callerTenantRole := types.TenantRoleFromContext(ctx)
|
||
kbID = secutils.SanitizeForLog(kbID)
|
||
if kbID == "" {
|
||
return nil, errors.NewBadRequestError("Knowledge base ID cannot be empty")
|
||
}
|
||
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, kbID)
|
||
if err != nil {
|
||
// ErrKnowledgeBaseNotFound is the expected response for a stale
|
||
// or probed kb id; the FAQ endpoints went out of their way to
|
||
// surface internal-server-error for those, which both confused
|
||
// clients (real 5xx vs. wrong URL) and burned ops attention on
|
||
// monitoring alerts. Mirror knowledgebase.go's mapping.
|
||
if stderrors.Is(err, repository.ErrKnowledgeBaseNotFound) {
|
||
return nil, errors.NewNotFoundError("knowledge base not found")
|
||
}
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
return nil, errors.NewInternalServerError(err.Error())
|
||
}
|
||
if kb.TenantID == tenantID {
|
||
return context.WithValue(ctx, types.TenantIDContextKey, tenantID), nil
|
||
}
|
||
if h.kbShareService != nil {
|
||
permission, isShared, permErr := h.kbShareService.CheckTenantKBPermission(ctx, kbID, tenantID, callerTenantRole)
|
||
if permErr == nil && isShared && permission.HasPermission(requiredPermission) {
|
||
sourceTenantID, srcErr := h.kbShareService.GetKBSourceTenant(ctx, kbID)
|
||
if srcErr == nil {
|
||
logger.Infof(ctx, "Tenant %d accessing shared KB %s with permission %s, source tenant: %d",
|
||
tenantID, kbID, permission, sourceTenantID)
|
||
return context.WithValue(ctx, types.TenantIDContextKey, sourceTenantID), nil
|
||
}
|
||
}
|
||
}
|
||
if requiredPermission == types.OrgRoleViewer && h.agentShareService != nil {
|
||
can, err := h.agentShareService.TenantCanAccessKBViaSomeSharedAgent(ctx, tenantID, callerTenantRole, kb)
|
||
if err == nil && can {
|
||
logger.Infof(ctx, "Tenant %d accessing KB %s via some shared agent", tenantID, kbID)
|
||
return context.WithValue(ctx, types.TenantIDContextKey, kb.TenantID), nil
|
||
}
|
||
}
|
||
_ = userID
|
||
_ = userExists
|
||
logger.Warnf(ctx, "Permission denied to access KB %s", kbID)
|
||
return nil, errors.NewForbiddenError("Permission denied to access this knowledge base")
|
||
}
|
||
|
||
// ListEntries godoc
|
||
// @Summary 获取FAQ条目列表
|
||
// @Description 获取知识库下的FAQ条目列表,支持分页和筛选
|
||
// @Tags FAQ管理
|
||
// @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 int false "标签ID筛选(seq_id)"
|
||
// @Param keyword query string false "关键词搜索"
|
||
// @Param search_field query string false "搜索字段: standard_question(标准问题), similar_questions(相似问法), answers(答案), 默认搜索全部"
|
||
// @Param sort_order query string false "排序方式: asc(按更新时间正序), 默认按更新时间倒序"
|
||
// @Success 200 {object} map[string]interface{} "FAQ列表"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries [get]
|
||
func (h *FAQHandler) ListEntries(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleViewer)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
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
|
||
}
|
||
|
||
var tagSeqID int64
|
||
tagIDStr := c.Query("tag_id")
|
||
if tagIDStr != "" {
|
||
var err error
|
||
tagSeqID, err = strconv.ParseInt(tagIDStr, 10, 64)
|
||
if err != nil {
|
||
c.Error(errors.NewBadRequestError("tag_id 必须是整数"))
|
||
return
|
||
}
|
||
}
|
||
keyword := secutils.SanitizeForLog(c.Query("keyword"))
|
||
searchField := secutils.SanitizeForLog(c.Query("search_field"))
|
||
sortOrder := secutils.SanitizeForLog(c.Query("sort_order"))
|
||
|
||
result, err := h.knowledgeService.ListFAQEntries(effCtx, kbID, &page, tagSeqID, keyword, searchField, sortOrder)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": result,
|
||
})
|
||
}
|
||
|
||
// UpsertEntries godoc
|
||
// @Summary 批量更新/插入FAQ条目
|
||
// @Description 异步批量更新或插入FAQ条目。支持 dry_run 模式(设置 dry_run=true),异步验证不实际导入。
|
||
// @Description dry_run 模式是异步操作,返回 task_id,通过 /faq/import/progress/{task_id} 查询进度和结果。
|
||
// @Description 验证内容包括:1) 条目基本格式 2) 重复问题(批次内和知识库已有) 3) 内容安全检查。
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param request body types.FAQBatchUpsertPayload true "批量操作请求"
|
||
// @Success 200 {object} map[string]interface{} "任务ID"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries [post]
|
||
func (h *FAQHandler) UpsertEntries(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req types.FAQBatchUpsertPayload
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind FAQ upsert payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
|
||
// 统一使用 UpsertFAQEntries,通过 DryRun 字段区分模式
|
||
taskID, err := h.knowledgeService.UpsertFAQEntries(effCtx, kbID, &req)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": gin.H{
|
||
"task_id": taskID,
|
||
},
|
||
})
|
||
}
|
||
|
||
// CreateEntry godoc
|
||
// @Summary 创建单个FAQ条目
|
||
// @Description 同步创建单个FAQ条目
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param request body types.FAQEntryPayload true "FAQ条目"
|
||
// @Success 200 {object} map[string]interface{} "创建的FAQ条目"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entry [post]
|
||
func (h *FAQHandler) CreateEntry(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req types.FAQEntryPayload
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind FAQ entry payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
|
||
entry, err := h.knowledgeService.CreateFAQEntry(effCtx, kbID, &req)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": entry,
|
||
})
|
||
}
|
||
|
||
// UpdateEntry godoc
|
||
// @Summary 更新FAQ条目
|
||
// @Description 更新指定的FAQ条目
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param entry_id path int true "FAQ条目ID(seq_id)"
|
||
// @Param request body types.FAQEntryPayload true "FAQ条目"
|
||
// @Success 200 {object} map[string]interface{} "更新成功"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries/{entry_id} [put]
|
||
func (h *FAQHandler) UpdateEntry(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req types.FAQEntryPayload
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind FAQ entry payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
|
||
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
|
||
if err != nil {
|
||
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
|
||
return
|
||
}
|
||
|
||
entry, err := h.knowledgeService.UpdateFAQEntry(effCtx,
|
||
kbID, entrySeqID, &req)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": entry,
|
||
})
|
||
}
|
||
|
||
// UpdateEntryTagBatch godoc
|
||
// @Summary 批量更新FAQ标签
|
||
// @Description 批量更新FAQ条目的标签
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库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}/faq/entries/tags [put]
|
||
func (h *FAQHandler) UpdateEntryTagBatch(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req faqEntryTagBatchRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind FAQ entry tag batch payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
if err := h.knowledgeService.UpdateFAQEntryTagBatch(effCtx,
|
||
kbID, req.Updates); err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
})
|
||
}
|
||
|
||
// UpdateEntryFieldsBatch godoc
|
||
// @Summary 批量更新FAQ字段
|
||
// @Description 批量更新FAQ条目的多个字段(is_enabled, is_recommended, tag_id)
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param request body types.FAQEntryFieldsBatchUpdate true "字段更新请求"
|
||
// @Success 200 {object} map[string]interface{} "更新成功"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries/fields [put]
|
||
func (h *FAQHandler) UpdateEntryFieldsBatch(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req types.FAQEntryFieldsBatchUpdate
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind FAQ entry fields batch payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
if err := h.knowledgeService.UpdateFAQEntryFieldsBatch(effCtx,
|
||
kbID, &req); err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
})
|
||
}
|
||
|
||
// faqDeleteRequest is a request for deleting FAQ entries in batch
|
||
type faqDeleteRequest struct {
|
||
IDs []int64 `json:"ids" binding:"required,min=1"`
|
||
}
|
||
|
||
// faqEntryTagBatchRequest is a request for updating tags for FAQ entries in batch
|
||
// key: entry seq_id, value: tag seq_id (nil to remove tag)
|
||
type faqEntryTagBatchRequest struct {
|
||
Updates map[int64]*int64 `json:"updates" binding:"required,min=1"`
|
||
}
|
||
|
||
// addSimilarQuestionsRequest is a request for adding similar questions to a FAQ entry
|
||
type addSimilarQuestionsRequest struct {
|
||
SimilarQuestions []string `json:"similar_questions" binding:"required,min=1"`
|
||
}
|
||
|
||
// DeleteEntries godoc
|
||
// @Summary 批量删除FAQ条目
|
||
// @Description 批量删除指定的FAQ条目
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param request body object{ids=[]int} true "要删除的FAQ ID列表(seq_id)"
|
||
// @Success 200 {object} map[string]interface{} "删除成功"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries [delete]
|
||
func (h *FAQHandler) DeleteEntries(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req faqDeleteRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Errorf(ctx, "Failed to bind FAQ delete payload: %s", secutils.SanitizeForLog(err.Error()))
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
|
||
if err := h.knowledgeService.DeleteFAQEntries(effCtx,
|
||
kbID,
|
||
req.IDs); err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
})
|
||
}
|
||
|
||
// SearchFAQ godoc
|
||
// @Summary 搜索FAQ
|
||
// @Description 使用混合搜索在FAQ中搜索,支持两级优先级标签召回:first_priority_tag_ids优先级最高,second_priority_tag_ids次之
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param request body types.FAQSearchRequest true "搜索请求"
|
||
// @Success 200 {object} map[string]interface{} "搜索结果"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/search [post]
|
||
func (h *FAQHandler) SearchFAQ(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleViewer)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
var req types.FAQSearchRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind FAQ search payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
req.QueryText = secutils.SanitizeForLog(req.QueryText)
|
||
if req.MatchCount <= 0 {
|
||
req.MatchCount = 10
|
||
}
|
||
if req.MatchCount > 200 {
|
||
req.MatchCount = 200
|
||
}
|
||
entries, err := h.knowledgeService.SearchFAQEntries(effCtx, kbID, &req)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": entries,
|
||
})
|
||
}
|
||
|
||
// ExportEntries godoc
|
||
// @Summary 导出FAQ条目
|
||
// @Description 将所有FAQ条目导出为CSV文件
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce text/csv
|
||
// @Param id path string true "知识库ID"
|
||
// @Success 200 {file} file "CSV文件"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries/export [get]
|
||
func (h *FAQHandler) ExportEntries(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleViewer)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
csvData, err := h.knowledgeService.ExportFAQEntries(effCtx, kbID)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
// Set response headers for CSV download
|
||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||
c.Header("Content-Disposition", "attachment; filename=faq_export.csv")
|
||
// Add BOM for Excel compatibility with UTF-8
|
||
bom := []byte{0xEF, 0xBB, 0xBF}
|
||
c.Data(http.StatusOK, "text/csv; charset=utf-8", append(bom, csvData...))
|
||
}
|
||
|
||
// GetEntry godoc
|
||
// @Summary 获取FAQ条目详情
|
||
// @Description 根据ID获取单个FAQ条目的详情
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param entry_id path int true "FAQ条目ID(seq_id)"
|
||
// @Success 200 {object} map[string]interface{} "FAQ条目详情"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Failure 404 {object} errors.AppError "条目不存在"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries/{entry_id} [get]
|
||
func (h *FAQHandler) GetEntry(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleViewer)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
|
||
if err != nil {
|
||
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
|
||
return
|
||
}
|
||
|
||
entry, err := h.knowledgeService.GetFAQEntry(effCtx, kbID, entrySeqID)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": entry,
|
||
})
|
||
}
|
||
|
||
// GetImportProgress godoc
|
||
// @Summary 获取FAQ导入进度
|
||
// @Description 获取FAQ导入任务的进度
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param task_id path string true "任务ID"
|
||
// @Success 200 {object} map[string]interface{} "导入进度"
|
||
// @Failure 404 {object} errors.AppError "任务不存在"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /faq/import/progress/{task_id} [get]
|
||
func (h *FAQHandler) GetImportProgress(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
taskID := secutils.SanitizeForLog(c.Param("task_id"))
|
||
|
||
progress, err := h.knowledgeService.GetFAQImportProgress(ctx, taskID)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": progress,
|
||
})
|
||
}
|
||
|
||
// updateLastFAQImportResultDisplayStatusRequest is the request payload for UpdateLastImportResultDisplayStatus
|
||
type updateLastFAQImportResultDisplayStatusRequest struct {
|
||
DisplayStatus string `json:"display_status" binding:"required,oneof=open close"`
|
||
}
|
||
|
||
// UpdateLastImportResultDisplayStatus godoc
|
||
// @Summary 更新FAQ最后一次导入结果显示状态
|
||
// @Description 更新FAQ知识库导入结果统计卡片的显示或隐藏状态
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param request body updateLastFAQImportResultDisplayStatusRequest true "状态更新请求"
|
||
// @Success 200 {object} map[string]interface{} "更新成功"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Failure 404 {object} errors.AppError "知识库不存在或无导入记录"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/import/last-result/display [put]
|
||
func (h *FAQHandler) UpdateLastImportResultDisplayStatus(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
var req updateLastFAQImportResultDisplayStatusRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind display status update payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
|
||
if err := h.knowledgeService.UpdateLastFAQImportResultDisplayStatus(effCtx, kbID, req.DisplayStatus); err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
})
|
||
}
|
||
|
||
// AddSimilarQuestions godoc
|
||
// @Summary 添加相似问
|
||
// @Description 向指定的FAQ条目添加相似问题
|
||
// @Tags FAQ管理
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param id path string true "知识库ID"
|
||
// @Param entry_id path int true "FAQ条目ID(seq_id)"
|
||
// @Param request body addSimilarQuestionsRequest true "相似问列表"
|
||
// @Success 200 {object} map[string]interface{} "更新后的FAQ条目"
|
||
// @Failure 400 {object} errors.AppError "请求参数错误"
|
||
// @Failure 404 {object} errors.AppError "条目不存在"
|
||
// @Security Bearer
|
||
// @Security ApiKeyAuth
|
||
// @Router /knowledge-bases/{id}/faq/entries/{entry_id}/similar-questions [post]
|
||
func (h *FAQHandler) AddSimilarQuestions(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||
effCtx, err := h.effectiveCtxForKB(c, kbID, types.OrgRoleEditor)
|
||
if err != nil {
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
|
||
if err != nil {
|
||
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
|
||
return
|
||
}
|
||
|
||
var req addSimilarQuestionsRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
logger.Error(ctx, "Failed to bind add similar questions payload", err)
|
||
c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error()))
|
||
return
|
||
}
|
||
|
||
entry, err := h.knowledgeService.AddSimilarQuestions(effCtx, kbID, entrySeqID, req.SimilarQuestions)
|
||
if err != nil {
|
||
logger.ErrorWithFields(ctx, err, nil)
|
||
c.Error(err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": entry,
|
||
})
|
||
}
|