From 14d7d3d9958bdf8e96a4d690bc380a9e628d8ae9 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Mon, 12 Jan 2026 14:30:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=20FAQ=20=E5=92=8C=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=BC=95=E5=85=A5=20seq=5Fid=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/chunk.go | 1 + client/faq.go | 72 +-- client/tag.go | 21 +- frontend/src/api/knowledge-base/index.ts | 13 +- .../knowledge/components/FAQEntryManager.vue | 68 +-- internal/application/repository/chunk.go | 28 ++ internal/application/repository/tag.go | 25 + internal/application/service/agent_service.go | 2 +- internal/application/service/knowledge.go | 435 +++++++++++++----- internal/handler/faq.go | 45 +- internal/handler/tag.go | 71 ++- internal/types/chunk.go | 2 + internal/types/faq.go | 59 +-- internal/types/interfaces/chunk.go | 4 + internal/types/interfaces/knowledge.go | 17 +- internal/types/interfaces/tag.go | 4 + internal/types/tag.go | 2 + migrations/mysql/000010_add_seq_id.down.sql | 5 + migrations/mysql/000010_add_seq_id.up.sql | 29 ++ .../versioned/000010_add_seq_id.down.sql | 11 + migrations/versioned/000010_add_seq_id.up.sql | 52 +++ 21 files changed, 716 insertions(+), 250 deletions(-) create mode 100644 migrations/mysql/000010_add_seq_id.down.sql create mode 100644 migrations/mysql/000010_add_seq_id.up.sql create mode 100644 migrations/versioned/000010_add_seq_id.down.sql create mode 100644 migrations/versioned/000010_add_seq_id.up.sql diff --git a/client/chunk.go b/client/chunk.go index b48fc407..b594efed 100644 --- a/client/chunk.go +++ b/client/chunk.go @@ -15,6 +15,7 @@ import ( // Chunks are the basic units of storage and indexing in the knowledge base type Chunk struct { ID string `json:"id"` // Unique identifier of the chunk + SeqID int64 `json:"seq_id"` // Auto-increment integer ID for external API usage KnowledgeID string `json:"knowledge_id"` // Identifier of the parent knowledge KnowledgeBaseID string `json:"knowledge_base_id"` // ID of the knowledge base TenantID uint64 `json:"tenant_id"` // Tenant ID diff --git a/client/faq.go b/client/faq.go index be4e3f9b..d082f971 100644 --- a/client/faq.go +++ b/client/faq.go @@ -12,11 +12,11 @@ import ( // FAQEntry represents a FAQ item stored under a knowledge base. type FAQEntry struct { - ID string `json:"id"` + ID int64 `json:"id"` ChunkID string `json:"chunk_id"` KnowledgeID string `json:"knowledge_id"` KnowledgeBaseID string `json:"knowledge_base_id"` - TagID string `json:"tag_id"` + TagID int64 `json:"tag_id"` TagName string `json:"tag_name"` IsEnabled bool `json:"is_enabled"` IsRecommended bool `json:"is_recommended"` @@ -35,12 +35,14 @@ type FAQEntry struct { // FAQEntryPayload is used to create or update a FAQ entry. type FAQEntryPayload struct { + // ID is optional, used for data migration to specify seq_id (must be less than auto-increment start value 100000000) + ID *int64 `json:"id,omitempty"` StandardQuestion string `json:"standard_question"` SimilarQuestions []string `json:"similar_questions,omitempty"` NegativeQuestions []string `json:"negative_questions,omitempty"` Answers []string `json:"answers"` AnswerStrategy *string `json:"answer_strategy,omitempty"` - TagID string `json:"tag_id,omitempty"` + TagID int64 `json:"tag_id,omitempty"` TagName string `json:"tag_name,omitempty"` IsEnabled *bool `json:"is_enabled,omitempty"` IsRecommended *bool `json:"is_recommended,omitempty"` @@ -57,9 +59,9 @@ type FAQBatchUpsertPayload struct { // FAQEntryFieldsUpdate represents the fields that can be updated for a single FAQ entry. type FAQEntryFieldsUpdate struct { - IsEnabled *bool `json:"is_enabled,omitempty"` - IsRecommended *bool `json:"is_recommended,omitempty"` - TagID *string `json:"tag_id,omitempty"` + IsEnabled *bool `json:"is_enabled,omitempty"` + IsRecommended *bool `json:"is_recommended,omitempty"` + TagID *int64 `json:"tag_id,omitempty"` } // FAQEntryFieldsBatchRequest updates multiple fields for FAQ entries in bulk. @@ -67,31 +69,33 @@ type FAQEntryFieldsUpdate struct { // 1. By entry ID: use ByID field // 2. By Tag: use ByTag field to apply the same update to all entries under a tag type FAQEntryFieldsBatchRequest struct { - // ByID updates by entry ID, key is entry ID - ByID map[string]FAQEntryFieldsUpdate `json:"by_id,omitempty"` - // ByTag updates all entries under a tag, key is tag ID (empty string for uncategorized) - ByTag map[string]FAQEntryFieldsUpdate `json:"by_tag,omitempty"` - // ExcludeIDs IDs to exclude from the ByTag update - ExcludeIDs []string `json:"exclude_ids,omitempty"` + // ByID updates by entry ID (seq_id), key is entry seq_id + ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"` + // ByTag updates all entries under a tag, key is tag seq_id (0 for uncategorized) + ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"` + // ExcludeIDs IDs (seq_id) to exclude from the ByTag update + ExcludeIDs []int64 `json:"exclude_ids,omitempty"` } // FAQEntryTagBatchRequest updates tags in bulk. +// key: entry seq_id, value: tag seq_id (nil to remove tag) type FAQEntryTagBatchRequest struct { - Updates map[string]*string `json:"updates"` + Updates map[int64]*int64 `json:"updates"` } // FAQDeleteRequest deletes entries in bulk. type FAQDeleteRequest struct { - IDs []string `json:"ids"` + IDs []int64 `json:"ids"` } // FAQSearchRequest represents the hybrid FAQ search request. type FAQSearchRequest struct { - QueryText string `json:"query_text"` - VectorThreshold float64 `json:"vector_threshold"` - MatchCount int `json:"match_count"` - FirstPriorityTagIDs []string `json:"first_priority_tag_ids"` // First priority tag IDs, highest priority - SecondPriorityTagIDs []string `json:"second_priority_tag_ids"` // Second priority tag IDs, lower than first + QueryText string `json:"query_text"` + VectorThreshold float64 `json:"vector_threshold"` + MatchCount int `json:"match_count"` + FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // First priority tag seq_ids, highest priority + SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // Second priority tag seq_ids, lower than first + OnlyRecommended bool `json:"only_recommended"` // Only return recommended entries } // FAQEntriesPage contains paginated FAQ results. @@ -146,10 +150,11 @@ type faqSimpleResponse struct { } // ListFAQEntries returns paginated FAQ entries under a knowledge base. +// tagSeqID: filter by tag seq_id (0 means no filter) // searchField: specifies which field to search in ("standard_question", "similar_questions", "answers", "" for all) // sortOrder: "asc" for time ascending (updated_at ASC), default is time descending (updated_at DESC) func (c *Client) ListFAQEntries(ctx context.Context, - knowledgeBaseID string, page, pageSize int, tagID string, keyword string, searchField string, sortOrder string, + knowledgeBaseID string, page, pageSize int, tagSeqID int64, keyword string, searchField string, sortOrder string, ) (*FAQEntriesPage, error) { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID) query := url.Values{} @@ -159,8 +164,8 @@ func (c *Client) ListFAQEntries(ctx context.Context, if pageSize > 0 { query.Add("page_size", strconv.Itoa(pageSize)) } - if tagID != "" { - query.Add("tag_id", tagID) + if tagSeqID != 0 { + query.Add("tag_id", strconv.FormatInt(tagSeqID, 10)) } if keyword != "" { query.Add("keyword", keyword) @@ -224,11 +229,11 @@ func (c *Client) CreateFAQEntry(ctx context.Context, return response.Data, nil } -// GetFAQEntry retrieves a single FAQ entry by ID. +// GetFAQEntry retrieves a single FAQ entry by seq_id. func (c *Client) GetFAQEntry(ctx context.Context, - knowledgeBaseID, entryID string, + knowledgeBaseID string, entrySeqID int64, ) (*FAQEntry, error) { - path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%s", knowledgeBaseID, entryID) + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d", knowledgeBaseID, entrySeqID) resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil) if err != nil { return nil, err @@ -243,9 +248,9 @@ func (c *Client) GetFAQEntry(ctx context.Context, // UpdateFAQEntry updates a single FAQ entry. func (c *Client) UpdateFAQEntry(ctx context.Context, - knowledgeBaseID, entryID string, payload *FAQEntryPayload, + knowledgeBaseID string, entrySeqID int64, payload *FAQEntryPayload, ) (*FAQEntry, error) { - path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%s", knowledgeBaseID, entryID) + path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d", knowledgeBaseID, entrySeqID) resp, err := c.doRequest(ctx, http.MethodPut, path, payload, nil) if err != nil { return nil, err @@ -261,10 +266,10 @@ func (c *Client) UpdateFAQEntry(ctx context.Context, // UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in bulk. // Supports updating is_enabled, is_recommended, tag_id in a single call. // Supports two modes: -// - byID: update by entry ID, key is entry ID -// - byTag: update all entries under a tag, key is tag ID (empty string for uncategorized) +// - byID: update by entry seq_id, key is entry seq_id +// - byTag: update all entries under a tag, key is tag seq_id (0 for uncategorized) func (c *Client) UpdateFAQEntryFieldsBatch(ctx context.Context, - knowledgeBaseID string, byID map[string]FAQEntryFieldsUpdate, byTag map[string]FAQEntryFieldsUpdate, excludeIDs []string, + knowledgeBaseID string, byID map[int64]FAQEntryFieldsUpdate, byTag map[int64]FAQEntryFieldsUpdate, excludeIDs []int64, ) error { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/fields", knowledgeBaseID) resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryFieldsBatchRequest{ByID: byID, ByTag: byTag, ExcludeIDs: excludeIDs}, nil) @@ -277,8 +282,9 @@ func (c *Client) UpdateFAQEntryFieldsBatch(ctx context.Context, } // UpdateFAQEntryTagBatch updates FAQ entry tags in bulk. +// key: entry seq_id, value: tag seq_id (nil to remove tag) func (c *Client) UpdateFAQEntryTagBatch(ctx context.Context, - knowledgeBaseID string, updates map[string]*string, + knowledgeBaseID string, updates map[int64]*int64, ) error { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/tags", knowledgeBaseID) resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryTagBatchRequest{Updates: updates}, nil) @@ -290,9 +296,9 @@ func (c *Client) UpdateFAQEntryTagBatch(ctx context.Context, return parseResponse(resp, &response) } -// DeleteFAQEntries deletes FAQ entries in bulk. +// DeleteFAQEntries deletes FAQ entries in bulk by seq_id. func (c *Client) DeleteFAQEntries(ctx context.Context, - knowledgeBaseID string, ids []string, + knowledgeBaseID string, ids []int64, ) error { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID) resp, err := c.doRequest(ctx, http.MethodDelete, path, &FAQDeleteRequest{IDs: ids}, nil) diff --git a/client/tag.go b/client/tag.go index a4e89093..cb0ee353 100644 --- a/client/tag.go +++ b/client/tag.go @@ -12,6 +12,7 @@ import ( // Tag represents a knowledge base tag. type Tag struct { ID string `json:"id"` + SeqID int64 `json:"seq_id"` TenantID uint64 `json:"tenant_id"` KnowledgeBaseID string `json:"knowledge_base_id"` Name string `json:"name"` @@ -121,6 +122,7 @@ func (c *Client) CreateTag(ctx context.Context, } // UpdateTag updates an existing tag. +// tagID can be either UUID or seq_id (as string). func (c *Client) UpdateTag(ctx context.Context, knowledgeBaseID, tagID string, payload *UpdateTagPayload, ) (*Tag, error) { @@ -137,12 +139,20 @@ func (c *Client) UpdateTag(ctx context.Context, return response.Data, nil } +// UpdateTagBySeqID updates an existing tag by seq_id. +func (c *Client) UpdateTagBySeqID(ctx context.Context, + knowledgeBaseID string, tagSeqID int64, payload *UpdateTagPayload, +) (*Tag, error) { + return c.UpdateTag(ctx, knowledgeBaseID, strconv.FormatInt(tagSeqID, 10), payload) +} + // DeleteTag deletes a tag. +// tagID can be either UUID or seq_id (as string). // Set force to true to delete even if the tag is referenced. // Set contentOnly to true to only delete the content under the tag but keep the tag itself. -// excludeIDs: IDs of chunks to exclude from deletion. +// excludeIDs: seq_ids of chunks to exclude from deletion. func (c *Client) DeleteTag(ctx context.Context, - knowledgeBaseID, tagID string, force bool, contentOnly bool, excludeIDs []string, + knowledgeBaseID, tagID string, force bool, contentOnly bool, excludeIDs []int64, ) error { path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags/%s", knowledgeBaseID, tagID) query := url.Values{} @@ -168,3 +178,10 @@ func (c *Client) DeleteTag(ctx context.Context, var response tagSimpleResponse return parseResponse(resp, &response) } + +// DeleteTagBySeqID deletes a tag by seq_id. +func (c *Client) DeleteTagBySeqID(ctx context.Context, + knowledgeBaseID string, tagSeqID int64, force bool, contentOnly bool, excludeIDs []int64, +) error { + return c.DeleteTag(ctx, knowledgeBaseID, strconv.FormatInt(tagSeqID, 10), force, contentOnly, excludeIDs) +} diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 4c28b3bc..4c5161d8 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -138,7 +138,7 @@ export function updateKnowledgeTagBatch(data: { updates: Record }) { +export function updateFAQEntryTagBatch(kbId: string, data: { updates: Record }) { return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/tags`, data); } @@ -169,7 +169,7 @@ export function createFAQEntry(kbId: string, data: any) { return post(`/api/v1/knowledge-bases/${kbId}/faq/entry`, data); } -export function updateFAQEntry(kbId: string, entryId: string, data: any) { +export function updateFAQEntry(kbId: string, entryId: number, data: any) { return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/${entryId}`, data); } @@ -180,19 +180,20 @@ export function updateFAQEntry(kbId: string, entryId: string, data: any) { export interface FAQEntryFieldsUpdate { is_enabled?: boolean is_recommended?: boolean - tag_id?: string | null + tag_id?: number | null } export interface FAQEntryFieldsBatchRequest { - by_id?: Record - by_tag?: Record + by_id?: Record + by_tag?: Record + exclude_ids?: number[] } export function updateFAQEntryFieldsBatch(kbId: string, data: FAQEntryFieldsBatchRequest) { return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/fields`, data); } -export function deleteFAQEntries(kbId: string, ids: string[]) { +export function deleteFAQEntries(kbId: string, ids: number[]) { return del(`/api/v1/knowledge-bases/${kbId}/faq/entries`, { ids }); } diff --git a/frontend/src/views/knowledge/components/FAQEntryManager.vue b/frontend/src/views/knowledge/components/FAQEntryManager.vue index 09855572..ee43f4d5 100644 --- a/frontend/src/views/knowledge/components/FAQEntryManager.vue +++ b/frontend/src/views/knowledge/components/FAQEntryManager.vue @@ -1324,11 +1324,11 @@ import FAQTagTooltip from '@/components/FAQTagTooltip.vue' import { useUIStore } from '@/stores/ui' interface FAQEntry { - id: string + id: number chunk_id: string knowledge_id: string knowledge_base_id: string - tag_id?: string + tag_id?: number is_enabled: boolean is_recommended: boolean standard_question: string @@ -1350,7 +1350,7 @@ interface FAQEntryPayload { similar_questions: string[] negative_questions: string[] answers: string[] - tag_id?: string + tag_id?: number tag_name?: string is_enabled?: boolean is_recommended?: boolean @@ -1367,9 +1367,9 @@ const uiStore = useUIStore() const loading = ref(false) const loadingMore = ref(false) const entries = ref([]) -const entryStatusLoading = reactive>({}) -const entryRecommendedLoading = reactive>({}) -const selectedRowKeys = ref([]) +const entryStatusLoading = reactive>({}) +const entryRecommendedLoading = reactive>({}) +const selectedRowKeys = ref([]) const scrollContainer = ref(null) const cardListRef = ref(null) const hasMore = ref(true) @@ -1410,7 +1410,16 @@ const newTagName = ref('') const editingTagId = ref(null) const editingTagName = ref('') const editingTagSubmitting = ref(false) -const tagMap = computed>(() => { +// tagMap uses seq_id as key for looking up by entry.tag_id +const tagMap = computed>(() => { + const map: Record = {} + tagList.value.forEach((tag) => { + map[tag.seq_id] = tag + }) + return map +}) +// tagMapById uses UUID as key for editing operations +const tagMapById = computed>(() => { const map: Record = {} tagList.value.forEach((tag) => { map[tag.id] = tag @@ -1420,10 +1429,10 @@ const tagMap = computed>(() => { // All tags are now regular tags (no pseudo-tag) const regularTags = computed(() => tagList.value) const tagDropdownOptions = computed(() => - regularTags.value.map((tag: any) => ({ content: tag.name, value: tag.id })), + regularTags.value.map((tag: any) => ({ content: tag.name, value: String(tag.seq_id) })), ) const tagSelectOptions = computed(() => - regularTags.value.map((tag: any) => ({ label: tag.name, value: tag.id })), + regularTags.value.map((tag: any) => ({ label: tag.name, value: tag.seq_id })), ) const sidebarCategoryCount = computed(() => tagList.value.length) const filteredTags = computed(() => { @@ -1476,13 +1485,13 @@ const loadKnowledgeList = async () => { const editorVisible = ref(false) const editorMode = ref<'create' | 'edit'>('create') -const currentEntryId = ref(null) +const currentEntryId = ref(null) const editorForm = reactive({ standard_question: '', similar_questions: [], negative_questions: [], answers: [], - tag_id: '', + tag_id: undefined, }) const editorFormRef = ref() const savingEntry = ref(false) @@ -1603,7 +1612,7 @@ const loadTags = async (reset = false) => { } } -const getTagName = (tagId?: string) => { +const getTagName = (tagId?: number) => { if (!tagId) return t('knowledgeBase.untagged') || '未分类' return tagMap.value[tagId]?.name || (t('knowledgeBase.untagged') || '未分类') } @@ -1695,7 +1704,7 @@ const submitEditTag = async () => { MessagePlugin.warning(t('knowledgeBase.tagNameRequired')) return } - if (name === tagMap.value[editingTagId.value]?.name) { + if (name === tagMapById.value[editingTagId.value]?.name) { cancelEditTag() return } @@ -1747,16 +1756,16 @@ const confirmDeleteTag = (tag: any) => { }) } -const handleEntryTagChange = async (entryId: string, value?: string) => { +const handleEntryTagChange = async (entryId: number, value?: string) => { if (!props.kbId) return const targetEntry = entries.value.find((item) => item.id === entryId) - const previousTagId = targetEntry ? targetEntry.tag_id : '' - const normalizedValue = value ?? '' + const previousTagId = targetEntry ? targetEntry.tag_id : undefined + const normalizedValue = value ? Number(value) : null if (normalizedValue === previousTagId) { return } try { - await updateFAQEntryTagBatch(props.kbId, { updates: { [entryId]: normalizedValue || null } }) + await updateFAQEntryTagBatch(props.kbId, { updates: { [entryId]: normalizedValue } }) MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess')) await loadEntries() await loadTags() @@ -1902,7 +1911,7 @@ const loadEntries = async (append = false) => { entries.value = [] selectedRowKeys.value = [] Object.keys(entryStatusLoading).forEach((key) => { - delete entryStatusLoading[key] + delete entryStatusLoading[Number(key)] }) } @@ -1933,7 +1942,6 @@ const loadEntries = async (append = false) => { similarCollapsed: true, // 相似问默认折叠 negativeCollapsed: true, // 反例默认折叠 answersCollapsed: true, // 答案默认折叠 - tag_id: entry.tag_id ? String(entry.tag_id) : '', is_enabled: entry.is_enabled !== false, })) @@ -1993,7 +2001,7 @@ const checkAndLoadMore = () => { } } -const handleCardSelect = (entryId: string, checked: boolean) => { +const handleCardSelect = (entryId: number, checked: boolean) => { if (checked) { if (!selectedRowKeys.value.includes(entryId)) { selectedRowKeys.value.push(entryId) @@ -2011,7 +2019,7 @@ const resetEditorForm = () => { editorForm.similar_questions = [] editorForm.negative_questions = [] editorForm.answers = [] - editorForm.tag_id = '' + editorForm.tag_id = undefined answerInput.value = '' similarInput.value = '' negativeInput.value = '' @@ -2025,7 +2033,7 @@ const openEditor = (entry?: FAQEntry) => { editorForm.similar_questions = [...(entry.similar_questions || [])] editorForm.negative_questions = [...(entry.negative_questions || [])] editorForm.answers = [...(entry.answers || [])] - editorForm.tag_id = entry.tag_id || '' + editorForm.tag_id = entry.tag_id || undefined } else { editorMode.value = 'create' currentEntryId.value = null @@ -2100,7 +2108,7 @@ const handleSubmitEntry = async () => { similar_questions: [...editorForm.similar_questions], negative_questions: [...editorForm.negative_questions], answers: [...editorForm.answers], - tag_id: editorForm.tag_id || '', + tag_id: editorForm.tag_id || undefined, } if (editorMode.value === 'create') { await createFAQEntry(props.kbId, payload) @@ -2143,9 +2151,9 @@ const openBatchTagDialog = () => { const handleBatchTag = async () => { if (!selectedRowKeys.value.length || !props.kbId) return try { - const updates: Record = {} + const updates: Record = {} selectedRowKeys.value.forEach(id => { - updates[id] = batchTagValue.value || null + updates[id] = batchTagValue.value ? Number(batchTagValue.value) : null }) await updateFAQEntryTagBatch(props.kbId, { updates }) MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess')) @@ -2161,7 +2169,7 @@ const handleBatchTag = async () => { const handleBatchStatusChange = async (isEnabled: boolean) => { if (!selectedRowKeys.value.length || !props.kbId) return try { - const by_id: Record = {} + const by_id: Record = {} selectedRowKeys.value.forEach(id => { by_id[id] = { is_enabled: isEnabled } }) @@ -2177,7 +2185,7 @@ const handleBatchStatusChange = async (isEnabled: boolean) => { const handleBatchRecommendedChange = async (isRecommended: boolean) => { if (!selectedRowKeys.value.length || !props.kbId) return try { - const by_id: Record = {} + const by_id: Record = {} selectedRowKeys.value.forEach(id => { by_id[id] = { is_recommended: isRecommended } }) @@ -2302,7 +2310,7 @@ const parseCSVFile = async (file: File): Promise => { answers: splitByDelimiter(record['机器人回答'] || record['answers']), similar_questions: splitByDelimiter(record['相似问题'] || record['similar_questions']), negative_questions: splitByDelimiter(record['反例问题'] || record['negative_questions']), - tag_id: record['tag_id'] || '', + tag_id: record['tag_id'] ? Number(record['tag_id']) : undefined, tag_name: record['分类'] || record['tag_name'] || '', is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反 }), @@ -2349,7 +2357,7 @@ const parseExcelFile = async (file: File): Promise => { answers: splitByDelimiter(normalizedRow['机器人回答'] || normalizedRow['answers']), similar_questions: splitByDelimiter(normalizedRow['相似问题'] || normalizedRow['similar_questions']), negative_questions: splitByDelimiter(normalizedRow['反例问题'] || normalizedRow['negative_questions']), - tag_id: normalizedRow['tag_id'] || '', + tag_id: normalizedRow['tag_id'] ? Number(normalizedRow['tag_id']) : undefined, tag_name: normalizedRow['分类'] || normalizedRow['tag_name'] || '', is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反 }) @@ -2392,7 +2400,7 @@ const normalizePayload = (payload: Partial): FAQEntryPayload => answers: payload.answers?.filter(Boolean) || [], similar_questions: payload.similar_questions?.filter(Boolean) || [], negative_questions: payload.negative_questions?.filter(Boolean) || [], - tag_id: payload.tag_id || '', + tag_id: payload.tag_id || undefined, tag_name: payload.tag_name || '', is_enabled: payload.is_enabled !== undefined ? payload.is_enabled : undefined, }) diff --git a/internal/application/repository/chunk.go b/internal/application/repository/chunk.go index a1b4be5b..28a7bb9d 100644 --- a/internal/application/repository/chunk.go +++ b/internal/application/repository/chunk.go @@ -44,6 +44,18 @@ func (r *chunkRepository) GetChunkByID(ctx context.Context, tenantID uint64, id return &chunk, nil } +// GetChunkBySeqID retrieves a chunk by its seq_id and tenant ID +func (r *chunkRepository) GetChunkBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.Chunk, error) { + var chunk types.Chunk + if err := r.db.WithContext(ctx).Where("tenant_id = ? AND seq_id = ?", tenantID, seqID).First(&chunk).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("chunk not found") + } + return nil, err + } + return &chunk, nil +} + // ListChunksByID retrieves multiple chunks by their IDs func (r *chunkRepository) ListChunksByID( ctx context.Context, tenantID uint64, ids []string, @@ -57,6 +69,22 @@ func (r *chunkRepository) ListChunksByID( return chunks, nil } +// ListChunksBySeqID retrieves multiple chunks by their seq_ids +func (r *chunkRepository) ListChunksBySeqID( + ctx context.Context, tenantID uint64, seqIDs []int64, +) ([]*types.Chunk, error) { + if len(seqIDs) == 0 { + return []*types.Chunk{}, nil + } + var chunks []*types.Chunk + if err := r.db.WithContext(ctx). + Where("tenant_id = ? AND seq_id IN ?", tenantID, seqIDs). + Find(&chunks).Error; err != nil { + return nil, err + } + return chunks, nil +} + // ListChunksByKnowledgeID lists all chunks for a knowledge ID func (r *chunkRepository) ListChunksByKnowledgeID( ctx context.Context, tenantID uint64, knowledgeID string, diff --git a/internal/application/repository/tag.go b/internal/application/repository/tag.go index 07de1373..907ab8bc 100644 --- a/internal/application/repository/tag.go +++ b/internal/application/repository/tag.go @@ -54,6 +54,31 @@ func (r *knowledgeTagRepository) GetByIDs(ctx context.Context, tenantID uint64, return tags, nil } +// GetBySeqID retrieves a tag by its seq_id +func (r *knowledgeTagRepository) GetBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.KnowledgeTag, error) { + var tag types.KnowledgeTag + if err := r.db.WithContext(ctx). + Where("tenant_id = ? AND seq_id = ?", tenantID, seqID). + First(&tag).Error; err != nil { + return nil, err + } + return &tag, nil +} + +// GetBySeqIDs retrieves multiple tags by their seq_ids in a single query +func (r *knowledgeTagRepository) GetBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.KnowledgeTag, error) { + if len(seqIDs) == 0 { + return []*types.KnowledgeTag{}, nil + } + var tags []*types.KnowledgeTag + if err := r.db.WithContext(ctx). + Where("tenant_id = ? AND seq_id IN (?)", tenantID, seqIDs). + Find(&tags).Error; err != nil { + return nil, err + } + return tags, nil +} + // GetByName gets a knowledge tag by name func (r *knowledgeTagRepository) GetByName(ctx context.Context, tenantID uint64, kbID string, name string) (*types.KnowledgeTag, error) { var tag types.KnowledgeTag diff --git a/internal/application/service/agent_service.go b/internal/application/service/agent_service.go index aa97352e..d332f52c 100644 --- a/internal/application/service/agent_service.go +++ b/internal/application/service/agent_service.go @@ -375,7 +375,7 @@ func (s *agentService) getKnowledgeBaseInfos(ctx context.Context, kbIDs []string pageResult, err := s.knowledgeService.ListFAQEntries(ctx, kbID, &types.Pagination{ Page: 1, PageSize: 10, - }, "", "", "", "") + }, 0, "", "", "") if err == nil && pageResult != nil { docCount = int(pageResult.Total) if entries, ok := pageResult.Data.([]*types.FAQEntry); ok { diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 7bafee43..91fd6cb5 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -2650,7 +2650,7 @@ func (s *knowledgeService) CloneChunk(ctx context.Context, src, dst *types.Knowl // ListFAQEntries lists FAQ entries under a FAQ knowledge base. func (s *knowledgeService) ListFAQEntries(ctx context.Context, - kbID string, page *types.Pagination, tagID string, keyword string, searchField string, sortOrder string, + kbID string, page *types.Pagination, tagSeqID int64, keyword string, searchField string, sortOrder string, ) (*types.PageResult, error) { if page == nil { page = &types.Pagination{} @@ -2668,6 +2668,17 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context, if faqKnowledge == nil { return types.NewPageResult(0, page, []*types.FAQEntry{}), nil } + + // Convert tagSeqID to tagID (UUID) + var tagID string + if tagSeqID > 0 { + tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, tagSeqID) + if err != nil { + return nil, werrors.NewNotFoundError("标签不存在") + } + tagID = tag.ID + } + chunkType := []types.ChunkType{types.ChunkTypeFAQ} chunks, total, err := s.chunkRepo.ListPagedChunksByKnowledgeID( ctx, tenantID, faqKnowledge.ID, page, chunkType, tagID, keyword, searchField, sortOrder, types.KnowledgeTypeFAQ, @@ -2676,8 +2687,9 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context, return nil, err } - // Build tag ID to name mapping for all unique tag IDs (batch query) + // Build tag ID to name and seq_id mapping for all unique tag IDs (batch query) tagNameMap := make(map[string]string) + tagSeqIDMap := make(map[string]int64) tagIDs := make([]string, 0) tagIDSet := make(map[string]struct{}) for _, chunk := range chunks { @@ -2693,6 +2705,7 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context, if err == nil { for _, tag := range tags { tagNameMap[tag.ID] = tag.Name + tagSeqIDMap[tag.ID] = tag.SeqID } } } @@ -2700,13 +2713,13 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context, kb.EnsureDefaults() entries := make([]*types.FAQEntry, 0, len(chunks)) for _, chunk := range chunks { - entry, err := s.chunkToFAQEntry(chunk, kb) + entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap) if err != nil { return nil, err } // Set tag name from mapping - if entry.TagID != "" { - entry.TagName = tagNameMap[entry.TagID] + if chunk.TagID != "" { + entry.TagName = tagNameMap[chunk.TagID] } entries = append(entries, entry) } @@ -3555,6 +3568,10 @@ func (s *knowledgeService) executeFAQImport(ctx context.Context, taskID string, TagID: tagID, // 使用解析后的 TagID Status: int(types.ChunkStatusStored), // store but not indexed } + // 如果指定了 ID(用于数据迁移),设置 SeqID + if entry.ID != nil && *entry.ID > 0 { + chunk.SeqID = *entry.ID + } if err := chunk.SetFAQMetadata(meta); err != nil { return fmt.Errorf("failed to set FAQ metadata: %w", err) } @@ -3719,6 +3736,10 @@ func (s *knowledgeService) CreateFAQEntry(ctx context.Context, TagID: tagID, // 使用解析后的 TagID Status: int(types.ChunkStatusStored), } + // 如果指定了 ID(用于数据迁移),设置 SeqID + if payload.ID != nil && *payload.ID > 0 { + chunk.SeqID = *payload.ID + } if err := chunk.SetFAQMetadata(meta); err != nil { return nil, fmt.Errorf("failed to set FAQ metadata: %w", err) @@ -3742,15 +3763,24 @@ func (s *knowledgeService) CreateFAQEntry(ctx context.Context, return nil, fmt.Errorf("failed to update chunk status: %w", err) } + // Build tag seq_id map for conversion + tagSeqIDMap := make(map[string]int64) + if chunk.TagID != "" { + tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID) + if tagErr == nil && tag != nil { + tagSeqIDMap[tag.ID] = tag.SeqID + } + } + // 转换为FAQEntry返回 - entry, err := s.chunkToFAQEntry(chunk, kb) + entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap) if err != nil { return nil, err } // 查询TagName - if entry.TagID != "" { - tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, entry.TagID) + if chunk.TagID != "" { + tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID) if tagErr == nil && tag != nil { entry.TagName = tag.Name } @@ -3759,11 +3789,11 @@ func (s *knowledgeService) CreateFAQEntry(ctx context.Context, return entry, nil } -// GetFAQEntry retrieves a single FAQ entry by ID. +// GetFAQEntry retrieves a single FAQ entry by seq_id. func (s *knowledgeService) GetFAQEntry(ctx context.Context, - kbID string, entryID string, + kbID string, entrySeqID int64, ) (*types.FAQEntry, error) { - if entryID == "" { + if entrySeqID <= 0 { return nil, werrors.NewBadRequestError("条目ID不能为空") } @@ -3775,10 +3805,10 @@ func (s *knowledgeService) GetFAQEntry(ctx context.Context, tenantID := ctx.Value(types.TenantIDContextKey).(uint64) - // 获取chunk - chunk, err := s.chunkService.GetChunkByID(ctx, entryID) + // 获取chunk by seq_id + chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID) if err != nil { - return nil, err + return nil, werrors.NewNotFoundError("FAQ条目不存在") } // 验证chunk属于当前知识库 @@ -3791,15 +3821,24 @@ func (s *knowledgeService) GetFAQEntry(ctx context.Context, return nil, werrors.NewNotFoundError("FAQ条目不存在") } + // Build tag seq_id map for conversion + tagSeqIDMap := make(map[string]int64) + if chunk.TagID != "" { + tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID) + if tagErr == nil && tag != nil { + tagSeqIDMap[tag.ID] = tag.SeqID + } + } + // 转换为FAQEntry返回 - entry, err := s.chunkToFAQEntry(chunk, kb) + entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap) if err != nil { return nil, err } // 查询TagName - if entry.TagID != "" { - tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, entry.TagID) + if chunk.TagID != "" { + tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID) if tagErr == nil && tag != nil { entry.TagName = tag.Name } @@ -3810,7 +3849,7 @@ func (s *knowledgeService) GetFAQEntry(ctx context.Context, // UpdateFAQEntry updates a single FAQ entry. func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, - kbID string, entryID string, payload *types.FAQEntryPayload, + kbID string, entrySeqID int64, payload *types.FAQEntryPayload, ) (*types.FAQEntry, error) { if payload == nil { return nil, werrors.NewBadRequestError("请求体不能为空") @@ -3821,9 +3860,10 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, } kb.EnsureDefaults() tenantID := ctx.Value(types.TenantIDContextKey).(uint64) - chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, entryID) + + chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID) if err != nil { - return nil, err + return nil, werrors.NewNotFoundError("FAQ条目不存在") } if chunk.KnowledgeBaseID != kb.ID { return nil, werrors.NewForbiddenError("无权操作该 FAQ 条目") @@ -3837,7 +3877,7 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, } // 检查标准问和相似问是否与其他条目重复 - if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, entryID, meta); err != nil { + if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, chunk.ID, meta); err != nil { return nil, err } @@ -3853,8 +3893,19 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, indexMode = kb.FAQConfig.IndexMode } chunk.Content = buildFAQChunkContent(meta, indexMode) - chunk.TagID = payload.TagID isEnabledUpdated := false + + // Convert tag seq_id to UUID + if payload.TagID > 0 { + tag, tagErr := s.tagRepo.GetBySeqID(ctx, tenantID, payload.TagID) + if tagErr != nil { + return nil, werrors.NewNotFoundError("标签不存在") + } + chunk.TagID = tag.ID + } else { + chunk.TagID = "" + } + if payload.IsEnabled != nil { oldEnabled := chunk.IsEnabled chunk.IsEnabled = *payload.IsEnabled @@ -3901,15 +3952,24 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context, return nil, err } + // Build tag seq_id map for conversion + tagSeqIDMap := make(map[string]int64) + if chunk.TagID != "" { + tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID) + if tagErr == nil && tag != nil { + tagSeqIDMap[tag.ID] = tag.SeqID + } + } + // 转换为FAQEntry返回 - entry, err := s.chunkToFAQEntry(chunk, kb) + entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap) if err != nil { return nil, err } // 查询TagName - if entry.TagID != "" { - tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, entry.TagID) + if chunk.TagID != "" { + tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID) if tagErr == nil && tag != nil { entry.TagName = tag.Name } @@ -3960,8 +4020,8 @@ func (s *knowledgeService) UpdateFAQEntryStatus(ctx context.Context, // UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in batch. // This is the unified API for batch updating FAQ entry fields. // Supports two modes: -// 1. By entry ID: use ByID field -// 2. By Tag: use ByTag field to apply the same update to all entries under a tag +// 1. By entry seq_id: use ByID field +// 2. By Tag seq_id: use ByTag field to apply the same update to all entries under a tag func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context, kbID string, req *types.FAQEntryFieldsBatchUpdate, ) error { @@ -3977,9 +4037,26 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context, enabledUpdates := make(map[string]bool) tagUpdates := make(map[string]string) - // Handle ByTag updates first + // Convert exclude seq_ids to UUIDs + excludeUUIDs := make([]string, 0, len(req.ExcludeIDs)) + if len(req.ExcludeIDs) > 0 { + excludeChunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, req.ExcludeIDs) + if err == nil { + for _, c := range excludeChunks { + excludeUUIDs = append(excludeUUIDs, c.ID) + } + } + } + + // Handle ByTag updates first (by tag seq_id) if len(req.ByTag) > 0 { - for tagID, update := range req.ByTag { + for tagSeqID, update := range req.ByTag { + // Convert tag seq_id to UUID + tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, tagSeqID) + if err != nil { + return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", tagSeqID)) + } + var setFlags, clearFlags types.ChunkFlags // Handle IsRecommended @@ -3991,10 +4068,25 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context, } } + // Convert new tag seq_id to UUID if provided + var newTagUUID *string + if update.TagID != nil { + if *update.TagID > 0 { + newTag, err := s.tagRepo.GetBySeqID(ctx, tenantID, *update.TagID) + if err != nil { + return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", *update.TagID)) + } + newTagUUID = &newTag.ID + } else { + emptyStr := "" + newTagUUID = &emptyStr + } + } + // Update all chunks with this tag affectedIDs, err := s.chunkRepo.UpdateChunkFieldsByTagID( - ctx, tenantID, kb.ID, tagID, - update.IsEnabled, setFlags, clearFlags, update.TagID, req.ExcludeIDs, + ctx, tenantID, kb.ID, tag.ID, + update.IsEnabled, setFlags, clearFlags, newTagUUID, excludeUUIDs, ) if err != nil { return err @@ -4007,36 +4099,42 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context, enabledUpdates[id] = *update.IsEnabled } } - if update.TagID != nil { + if newTagUUID != nil { for _, id := range affectedIDs { - tagUpdates[id] = *update.TagID + tagUpdates[id] = *newTagUUID } } } } } - // Handle ByID updates + // Handle ByID updates (by entry seq_id) if len(req.ByID) > 0 { - entryIDs := make([]string, 0, len(req.ByID)) - for entryID := range req.ByID { - entryIDs = append(entryIDs, entryID) + entrySeqIDs := make([]int64, 0, len(req.ByID)) + for entrySeqID := range req.ByID { + entrySeqIDs = append(entrySeqIDs, entrySeqID) } - chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, entryIDs) + chunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, entrySeqIDs) if err != nil { return err } + // Build chunk seq_id to chunk map + chunkBySeqID := make(map[int64]*types.Chunk) + for _, chunk := range chunks { + chunkBySeqID[chunk.SeqID] = chunk + } + setFlags := make(map[string]types.ChunkFlags) clearFlags := make(map[string]types.ChunkFlags) chunksToUpdate := make([]*types.Chunk, 0) - for _, chunk := range chunks { - if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ { + for entrySeqID, update := range req.ByID { + chunk, exists := chunkBySeqID[entrySeqID] + if !exists { continue } - update, exists := req.ByID[chunk.ID] - if !exists { + if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ { continue } @@ -4061,11 +4159,15 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context, } } - // Handle TagID + // Handle TagID (convert seq_id to UUID) if update.TagID != nil { - newTagID := "" - if *update.TagID != "" { - newTagID = *update.TagID + var newTagID string + if *update.TagID > 0 { + newTag, err := s.tagRepo.GetBySeqID(ctx, tenantID, *update.TagID) + if err != nil { + return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", *update.TagID)) + } + newTagID = newTag.ID } if chunk.TagID != newTagID { chunk.TagID = newTagID @@ -4267,7 +4369,8 @@ func (s *knowledgeService) UpdateFAQEntryTag(ctx context.Context, kbID string, e } // UpdateFAQEntryTagBatch updates tags for FAQ entries in batch. -func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[string]*string) error { +// Key: entry seq_id, Value: tag seq_id (nil to remove tag) +func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error { if len(updates) == 0 { return nil } @@ -4277,59 +4380,65 @@ func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID stri } tenantID := ctx.Value(types.TenantIDContextKey).(uint64) - // Get all chunks in batch - entryIDs := make([]string, 0, len(updates)) - for entryID := range updates { - entryIDs = append(entryIDs, entryID) + // Get all chunks in batch by seq_id + entrySeqIDs := make([]int64, 0, len(updates)) + for entrySeqID := range updates { + entrySeqIDs = append(entrySeqIDs, entrySeqID) } - chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, entryIDs) + chunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, entrySeqIDs) if err != nil { return err } - // Build tag ID map for validation - tagIDSet := make(map[string]bool) - for _, tagID := range updates { - if tagID != nil && *tagID != "" { - tagIDSet[*tagID] = true + // Build chunk seq_id to chunk map + chunkBySeqID := make(map[int64]*types.Chunk) + for _, chunk := range chunks { + chunkBySeqID[chunk.SeqID] = chunk + } + + // Build tag seq_id set for validation + tagSeqIDSet := make(map[int64]bool) + for _, tagSeqID := range updates { + if tagSeqID != nil && *tagSeqID > 0 { + tagSeqIDSet[*tagSeqID] = true } } - // Validate all tags in batch - tagMap := make(map[string]*types.KnowledgeTag) - if len(tagIDSet) > 0 { - tagIDs := make([]string, 0, len(tagIDSet)) - for tagID := range tagIDSet { - tagIDs = append(tagIDs, tagID) + // Validate all tags in batch by seq_id + tagMap := make(map[int64]*types.KnowledgeTag) + if len(tagSeqIDSet) > 0 { + tagSeqIDs := make([]int64, 0, len(tagSeqIDSet)) + for tagSeqID := range tagSeqIDSet { + tagSeqIDs = append(tagSeqIDs, tagSeqID) } - for _, tagID := range tagIDs { - tag, err := s.tagRepo.GetByID(ctx, tenantID, tagID) - if err != nil { - return err - } + tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, tagSeqIDs) + if err != nil { + return err + } + for _, tag := range tags { if tag.KnowledgeBaseID != kb.ID { - return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不属于当前知识库", tagID)) + return werrors.NewBadRequestError(fmt.Sprintf("标签 %d 不属于当前知识库", tag.SeqID)) } - tagMap[tagID] = tag + tagMap[tag.SeqID] = tag } } // Update chunks chunksToUpdate := make([]*types.Chunk, 0) - for _, chunk := range chunks { - if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ { + for entrySeqID, tagSeqID := range updates { + chunk, exists := chunkBySeqID[entrySeqID] + if !exists { continue } - tagID, exists := updates[chunk.ID] - if !exists { + if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ { continue } var resolvedTagID string - if tagID != nil && *tagID != "" { - tag, ok := tagMap[*tagID] + if tagSeqID != nil && *tagSeqID > 0 { + tag, ok := tagMap[*tagSeqID] if !ok { - return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不存在", *tagID)) + return werrors.NewBadRequestError(fmt.Sprintf("标签 %d 不存在", *tagSeqID)) } resolvedTagID = tag.ID } @@ -4386,17 +4495,45 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, req.MatchCount = 50 } - // Build priority tag sets for sorting - hasFirstPriority := len(req.FirstPriorityTagIDs) > 0 - hasSecondPriority := len(req.SecondPriorityTagIDs) > 0 + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + + // Convert tag seq_ids to UUIDs + var firstPriorityTagUUIDs, secondPriorityTagUUIDs []string + firstPrioritySeqIDSet := make(map[int64]struct{}) + secondPrioritySeqIDSet := make(map[int64]struct{}) + + if len(req.FirstPriorityTagIDs) > 0 { + tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, req.FirstPriorityTagIDs) + if err == nil { + firstPriorityTagUUIDs = make([]string, 0, len(tags)) + for _, tag := range tags { + firstPriorityTagUUIDs = append(firstPriorityTagUUIDs, tag.ID) + firstPrioritySeqIDSet[tag.SeqID] = struct{}{} + } + } + } + if len(req.SecondPriorityTagIDs) > 0 { + tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, req.SecondPriorityTagIDs) + if err == nil { + secondPriorityTagUUIDs = make([]string, 0, len(tags)) + for _, tag := range tags { + secondPriorityTagUUIDs = append(secondPriorityTagUUIDs, tag.ID) + secondPrioritySeqIDSet[tag.SeqID] = struct{}{} + } + } + } + + // Build priority tag sets for sorting (using UUID) + hasFirstPriority := len(firstPriorityTagUUIDs) > 0 + hasSecondPriority := len(secondPriorityTagUUIDs) > 0 hasPriorityFilter := hasFirstPriority || hasSecondPriority - firstPrioritySet := make(map[string]struct{}, len(req.FirstPriorityTagIDs)) - for _, tagID := range req.FirstPriorityTagIDs { + firstPrioritySet := make(map[string]struct{}, len(firstPriorityTagUUIDs)) + for _, tagID := range firstPriorityTagUUIDs { firstPrioritySet[tagID] = struct{}{} } - secondPrioritySet := make(map[string]struct{}, len(req.SecondPriorityTagIDs)) - for _, tagID := range req.SecondPriorityTagIDs { + secondPrioritySet := make(map[string]struct{}, len(secondPriorityTagUUIDs)) + for _, tagID := range secondPriorityTagUUIDs { secondPrioritySet[tagID] = struct{}{} } @@ -4423,7 +4560,8 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, VectorThreshold: req.VectorThreshold, MatchCount: req.MatchCount, DisableKeywordsMatch: true, - TagIDs: req.FirstPriorityTagIDs, + TagIDs: firstPriorityTagUUIDs, + OnlyRecommended: req.OnlyRecommended, } firstResults, firstErr = s.kbService.HybridSearch(ctx, kbID, firstParams) }() @@ -4438,7 +4576,8 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, VectorThreshold: req.VectorThreshold, MatchCount: req.MatchCount, DisableKeywordsMatch: true, - TagIDs: req.SecondPriorityTagIDs, + TagIDs: secondPriorityTagUUIDs, + OnlyRecommended: req.OnlyRecommended, } secondResults, secondErr = s.kbService.HybridSearch(ctx, kbID, secondParams) }() @@ -4500,12 +4639,32 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, } // Batch fetch chunks - tenantID := ctx.Value(types.TenantIDContextKey).(uint64) chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs) if err != nil { return nil, err } + // Build tag UUID to seq_id map for conversion + tagSeqIDMap := make(map[string]int64) + tagIDs := make([]string, 0) + tagIDSet := make(map[string]struct{}) + for _, chunk := range chunks { + if chunk.TagID != "" { + if _, exists := tagIDSet[chunk.TagID]; !exists { + tagIDSet[chunk.TagID] = struct{}{} + tagIDs = append(tagIDs, chunk.TagID) + } + } + } + if len(tagIDs) > 0 { + tags, err := s.tagRepo.GetByIDs(ctx, tenantID, tagIDs) + if err == nil { + for _, tag := range tags { + tagSeqIDMap[tag.ID] = tag.SeqID + } + } + } + // Filter FAQ chunks and convert to FAQEntry kb.EnsureDefaults() entries := make([]*types.FAQEntry, 0, len(chunks)) @@ -4518,7 +4677,7 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, continue } - entry, err := s.chunkToFAQEntry(chunk, kb) + entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap) if err != nil { logger.Warnf(ctx, "Failed to convert chunk to FAQ entry: %v", err) continue @@ -4539,19 +4698,37 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, // Sort entries with two-level priority tag support if hasPriorityFilter { // getPriorityLevel returns: 0 = first priority, 1 = second priority, 2 = no priority - getPriorityLevel := func(tagID string) int { - if _, ok := firstPrioritySet[tagID]; ok { + // Use chunk.TagID (UUID) for comparison + getPriorityLevel := func(chunk *types.Chunk) int { + if _, ok := firstPrioritySet[chunk.TagID]; ok { return 0 } - if _, ok := secondPrioritySet[tagID]; ok { + if _, ok := secondPrioritySet[chunk.TagID]; ok { return 1 } return 2 } + // Build chunk map for priority lookup + chunkMap := make(map[int64]*types.Chunk) + for _, chunk := range chunks { + chunkMap[chunk.SeqID] = chunk + } + slices.SortFunc(entries, func(a, b *types.FAQEntry) int { - aPriority := getPriorityLevel(a.TagID) - bPriority := getPriorityLevel(b.TagID) + aChunk := chunkMap[a.ID] + bChunk := chunkMap[b.ID] + var aPriority, bPriority int + if aChunk != nil { + aPriority = getPriorityLevel(aChunk) + } else { + aPriority = 2 + } + if bChunk != nil { + bPriority = getPriorityLevel(bChunk) + } else { + bPriority = 2 + } // Compare by priority level first if aPriority != bPriority { @@ -4585,33 +4762,33 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, // 批量查询TagName并补充到结果中 if len(entries) > 0 { - // 收集所有需要查询的TagID - tagIDs := make([]string, 0) - tagIDSet := make(map[string]struct{}) + // 收集所有需要查询的TagID (seq_id) + tagSeqIDs := make([]int64, 0) + tagSeqIDSet := make(map[int64]struct{}) for _, entry := range entries { - if entry.TagID != "" { - if _, exists := tagIDSet[entry.TagID]; !exists { - tagIDs = append(tagIDs, entry.TagID) - tagIDSet[entry.TagID] = struct{}{} + if entry.TagID != 0 { + if _, exists := tagSeqIDSet[entry.TagID]; !exists { + tagSeqIDs = append(tagSeqIDs, entry.TagID) + tagSeqIDSet[entry.TagID] = struct{}{} } } } // 批量查询标签 - if len(tagIDs) > 0 { - tags, err := s.tagRepo.GetByIDs(ctx, tenantID, tagIDs) + if len(tagSeqIDs) > 0 { + tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, tagSeqIDs) if err != nil { logger.Warnf(ctx, "Failed to batch query tags: %v", err) } else { - // 构建TagID到TagName的映射 - tagNameMap := make(map[string]string) + // 构建TagSeqID到TagName的映射 + tagNameMap := make(map[int64]string) for _, tag := range tags { - tagNameMap[tag.ID] = tag.Name + tagNameMap[tag.SeqID] = tag.Name } // 补充TagName for _, entry := range entries { - if entry.TagID != "" { + if entry.TagID != 0 { if tagName, exists := tagNameMap[entry.TagID]; exists { entry.TagName = tagName } @@ -4624,11 +4801,11 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context, return entries, nil } -// DeleteFAQEntries deletes FAQ entries in batch. +// DeleteFAQEntries deletes FAQ entries in batch by seq_id. func (s *knowledgeService) DeleteFAQEntries(ctx context.Context, - kbID string, entryIDs []string, + kbID string, entrySeqIDs []int64, ) error { - if len(entryIDs) == 0 { + if len(entrySeqIDs) == 0 { return werrors.NewBadRequestError("请选择需要删除的 FAQ 条目") } kb, err := s.validateFAQKnowledgeBase(ctx, kbID) @@ -4638,19 +4815,19 @@ func (s *knowledgeService) DeleteFAQEntries(ctx context.Context, tenantID := ctx.Value(types.TenantIDContextKey).(uint64) var faqKnowledge *types.Knowledge - chunksToRemove := make([]*types.Chunk, 0, len(entryIDs)) - for _, id := range entryIDs { - if id == "" { + chunksToRemove := make([]*types.Chunk, 0, len(entrySeqIDs)) + for _, seqID := range entrySeqIDs { + if seqID <= 0 { continue } - chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, id) + chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, seqID) if err != nil { - return err + return werrors.NewNotFoundError("FAQ条目不存在") } if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ { return werrors.NewBadRequestError("包含无效的 FAQ 条目") } - if err := s.chunkService.DeleteChunk(ctx, id); err != nil { + if err := s.chunkService.DeleteChunk(ctx, chunk.ID); err != nil { return err } if faqKnowledge == nil { @@ -4923,7 +5100,7 @@ func (s *knowledgeService) clearRunningFAQImportTaskID(ctx context.Context, kbID return s.redisClient.Del(ctx, key).Err() } -func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.KnowledgeBase) (*types.FAQEntry, error) { +func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.KnowledgeBase, tagSeqIDMap map[string]int64) (*types.FAQEntry, error) { meta, err := chunk.FAQMetadata() if err != nil { return nil, err @@ -4936,12 +5113,19 @@ func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.Knowled if answerStrategy == "" { answerStrategy = types.AnswerStrategyAll } + + // Get tag seq_id from map + var tagSeqID int64 + if chunk.TagID != "" && tagSeqIDMap != nil { + tagSeqID = tagSeqIDMap[chunk.TagID] + } + entry := &types.FAQEntry{ - ID: chunk.ID, + ID: chunk.SeqID, ChunkID: chunk.ID, KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID, - TagID: chunk.TagID, + TagID: tagSeqID, IsEnabled: chunk.IsEnabled, IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended), StandardQuestion: meta.StandardQuestion, @@ -5036,12 +5220,19 @@ func (s *knowledgeService) checkFAQQuestionDuplicate( return nil } -// resolveTagID resolves tag ID from payload, prioritizing tag_id over tag_name +// resolveTagID resolves tag ID (UUID) from payload, prioritizing tag_id (seq_id) over tag_name // If no tag is specified, creates or finds the "未分类" tag +// Returns the internal UUID of the tag func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (string, error) { - // 如果提供了 tag_id,优先使用 tag_id - if payload.TagID != "" { - return payload.TagID, nil + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + + // 如果提供了 tag_id (seq_id),优先使用 tag_id + if payload.TagID != 0 { + tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, payload.TagID) + if err != nil { + return "", fmt.Errorf("failed to find tag by seq_id %d: %w", payload.TagID, err) + } + return tag.ID, nil } // 如果提供了 tag_name,查找或创建标签 diff --git a/internal/handler/faq.go b/internal/handler/faq.go index 84878b17..86d066e1 100644 --- a/internal/handler/faq.go +++ b/internal/handler/faq.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -31,7 +32,7 @@ func NewFAQHandler(knowledgeService interfaces.KnowledgeService) *FAQHandler { // @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 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(按更新时间正序), 默认按更新时间倒序" @@ -49,12 +50,21 @@ func (h *FAQHandler) ListEntries(c *gin.Context) { return } - tagID := secutils.SanitizeForLog(c.Query("tag_id")) + 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(ctx, secutils.SanitizeForLog(c.Param("id")), &page, tagID, keyword, searchField, sortOrder) + result, err := h.knowledgeService.ListFAQEntries(ctx, secutils.SanitizeForLog(c.Param("id")), &page, tagSeqID, keyword, searchField, sortOrder) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(err) @@ -151,7 +161,7 @@ func (h *FAQHandler) CreateEntry(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "知识库ID" -// @Param entry_id path string true "FAQ条目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 "请求参数错误" @@ -167,8 +177,14 @@ func (h *FAQHandler) UpdateEntry(c *gin.Context) { 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(ctx, - secutils.SanitizeForLog(c.Param("id")), secutils.SanitizeForLog(c.Param("entry_id")), &req) + secutils.SanitizeForLog(c.Param("id")), entrySeqID, &req) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(err) @@ -247,12 +263,13 @@ func (h *FAQHandler) UpdateEntryFieldsBatch(c *gin.Context) { // faqDeleteRequest is a request for deleting FAQ entries in batch type faqDeleteRequest struct { - IDs []string `json:"ids" binding:"required,min=1,dive,required"` + 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[string]*string `json:"updates" binding:"required,min=1"` + Updates map[int64]*int64 `json:"updates" binding:"required,min=1"` } // DeleteEntries godoc @@ -262,7 +279,7 @@ type faqEntryTagBatchRequest struct { // @Accept json // @Produce json // @Param id path string true "知识库ID" -// @Param request body object{ids=[]string} true "要删除的FAQ 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 @@ -279,7 +296,7 @@ func (h *FAQHandler) DeleteEntries(c *gin.Context) { if err := h.knowledgeService.DeleteFAQEntries(ctx, secutils.SanitizeForLog(c.Param("id")), - secutils.SanitizeForLogArray(req.IDs)); err != nil { + req.IDs); err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(err) return @@ -369,7 +386,7 @@ func (h *FAQHandler) ExportEntries(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "知识库ID" -// @Param entry_id path string true "FAQ条目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 "条目不存在" @@ -379,9 +396,13 @@ func (h *FAQHandler) ExportEntries(c *gin.Context) { func (h *FAQHandler) GetEntry(c *gin.Context) { ctx := c.Request.Context() kbID := secutils.SanitizeForLog(c.Param("id")) - entryID := secutils.SanitizeForLog(c.Param("entry_id")) + 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(ctx, kbID, entryID) + entry, err := h.knowledgeService.GetFAQEntry(ctx, kbID, entrySeqID) if err != nil { logger.ErrorWithFields(ctx, err, nil) c.Error(err) diff --git a/internal/handler/tag.go b/internal/handler/tag.go index 3bd8d8f3..17043729 100644 --- a/internal/handler/tag.go +++ b/internal/handler/tag.go @@ -1,7 +1,9 @@ package handler import ( + "context" "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -15,16 +17,44 @@ import ( // TagHandler handles knowledge base tag operations. 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 []string `json:"exclude_ids"` // Chunk IDs to exclude from deletion + ExcludeIDs []int64 `json:"exclude_ids"` // Chunk seq_ids to exclude from deletion } // NewTagHandler creates a new TagHandler. -func NewTagHandler(tagService interfaces.KnowledgeTagService) *TagHandler { - return &TagHandler{tagService: tagService} +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). +// Returns the UUID of the tag. +func (h *TagHandler) resolveTagID(c *gin.Context) (string, error) { + ctx := c.Request.Context() + tagIDParam := secutils.SanitizeForLog(c.Param("tag_id")) + + // Try to parse as integer (seq_id) + if seqID, err := strconv.ParseInt(tagIDParam, 10, 64); err == nil { + // It's an integer, look up by seq_id + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + tag, err := h.tagRepo.GetBySeqID(ctx, tenantID, seqID) + if err != nil { + return "", errors.NewNotFoundError("标签不存在") + } + return tag.ID, nil + } + + // It's a UUID string + 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 @@ -127,7 +157,7 @@ type updateTagRequest struct { // @Accept json // @Produce json // @Param id path string true "知识库ID" -// @Param tag_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 "请求参数错误" @@ -137,7 +167,12 @@ type updateTagRequest struct { func (h *TagHandler) UpdateTag(c *gin.Context) { ctx := c.Request.Context() - tagID := secutils.SanitizeForLog(c.Param("tag_id")) + 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) @@ -167,7 +202,7 @@ func (h *TagHandler) UpdateTag(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "知识库ID" -// @Param tag_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 "删除选项" @@ -178,7 +213,12 @@ func (h *TagHandler) UpdateTag(c *gin.Context) { // @Router /knowledge-bases/{id}/tags/{tag_id} [delete] func (h *TagHandler) DeleteTag(c *gin.Context) { ctx := c.Request.Context() - tagID := secutils.SanitizeForLog(c.Param("tag_id")) + + tagID, err := h.resolveTagID(c) + if err != nil { + c.Error(err) + return + } force := c.Query("force") == "true" contentOnly := c.Query("content_only") == "true" @@ -187,7 +227,22 @@ func (h *TagHandler) DeleteTag(c *gin.Context) { // Ignore bind error since body is optional _ = c.ShouldBindJSON(&req) - if err := h.tagService.DeleteTag(ctx, tagID, force, contentOnly, req.ExcludeIDs); err != nil { + // Convert seq_ids to UUIDs for excludeIDs + var excludeUUIDs []string + if len(req.ExcludeIDs) > 0 { + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + 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, }) diff --git a/internal/types/chunk.go b/internal/types/chunk.go index 0c79314f..fa3cf7ea 100644 --- a/internal/types/chunk.go +++ b/internal/types/chunk.go @@ -102,6 +102,8 @@ type ImageInfo struct { type Chunk struct { // Unique identifier of the chunk, using UUID format ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + // SeqID is an auto-increment integer ID for external API usage (FAQ entries) + SeqID int64 `json:"seq_id" gorm:"type:bigint;uniqueIndex"` // Tenant ID, used for multi-tenant isolation TenantID uint64 `json:"tenant_id"` // ID of the parent knowledge, associated with the Knowledge model diff --git a/internal/types/faq.go b/internal/types/faq.go index 7d910c17..fb663b77 100644 --- a/internal/types/faq.go +++ b/internal/types/faq.go @@ -175,11 +175,11 @@ const ( // FAQEntry 表示返回给前端的 FAQ 条目 type FAQEntry struct { - ID string `json:"id"` + ID int64 `json:"id"` ChunkID string `json:"chunk_id"` KnowledgeID string `json:"knowledge_id"` KnowledgeBaseID string `json:"knowledge_base_id"` - TagID string `json:"tag_id"` + TagID int64 `json:"tag_id"` TagName string `json:"tag_name"` IsEnabled bool `json:"is_enabled"` IsRecommended bool `json:"is_recommended"` @@ -198,12 +198,14 @@ type FAQEntry struct { // FAQEntryPayload 用于创建/更新 FAQ 条目的 payload type FAQEntryPayload struct { + // ID 可选,用于数据迁移时指定 seq_id(必须小于自增起始值 100000000) + ID *int64 `json:"id,omitempty"` StandardQuestion string `json:"standard_question" binding:"required"` SimilarQuestions []string `json:"similar_questions"` NegativeQuestions []string `json:"negative_questions"` Answers []string `json:"answers" binding:"required"` AnswerStrategy *AnswerStrategy `json:"answer_strategy,omitempty"` - TagID string `json:"tag_id"` + TagID int64 `json:"tag_id"` TagName string `json:"tag_name"` IsEnabled *bool `json:"is_enabled,omitempty"` IsRecommended *bool `json:"is_recommended,omitempty"` @@ -247,11 +249,12 @@ type FAQDryRunResult struct { // FAQSearchRequest FAQ检索请求参数 type FAQSearchRequest struct { - QueryText string `json:"query_text" binding:"required"` - VectorThreshold float64 `json:"vector_threshold"` - MatchCount int `json:"match_count"` - FirstPriorityTagIDs []string `json:"first_priority_tag_ids"` // 第一优先级标签ID列表,限定命中范围,优先级最高 - SecondPriorityTagIDs []string `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级 + QueryText string `json:"query_text" binding:"required"` + VectorThreshold float64 `json:"vector_threshold"` + MatchCount int `json:"match_count"` + FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // 第一优先级标签ID列表,限定命中范围,优先级最高 + SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级 + OnlyRecommended bool `json:"only_recommended"` // 是否仅返回推荐的条目 } // UntaggedTagName is the default tag name for entries without a tag @@ -259,9 +262,9 @@ const UntaggedTagName = "未分类" // FAQEntryFieldsUpdate 单个FAQ条目的字段更新 type FAQEntryFieldsUpdate struct { - IsEnabled *bool `json:"is_enabled,omitempty"` - IsRecommended *bool `json:"is_recommended,omitempty"` - TagID *string `json:"tag_id,omitempty"` + IsEnabled *bool `json:"is_enabled,omitempty"` + IsRecommended *bool `json:"is_recommended,omitempty"` + TagID *int64 `json:"tag_id,omitempty"` // 后续可扩展更多字段 } @@ -270,12 +273,12 @@ type FAQEntryFieldsUpdate struct { // 1. 按条目ID更新:使用 ByID 字段 // 2. 按Tag更新:使用 ByTag 字段,将该Tag下所有条目应用相同的更新 type FAQEntryFieldsBatchUpdate struct { - // ByID 按条目ID更新,key为条目ID - ByID map[string]FAQEntryFieldsUpdate `json:"by_id,omitempty"` - // ByTag 按Tag批量更新,key为TagID - ByTag map[string]FAQEntryFieldsUpdate `json:"by_tag,omitempty"` - // ExcludeIDs 在ByTag操作中需要排除的ID列表 - ExcludeIDs []string `json:"exclude_ids,omitempty"` + // ByID 按条目ID更新,key为条目ID (seq_id) + ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"` + // ByTag 按Tag批量更新,key为TagID (seq_id) + ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"` + // ExcludeIDs 在ByTag操作中需要排除的ID列表 (seq_id) + ExcludeIDs []int64 `json:"exclude_ids,omitempty"` } // FAQImportTaskStatus 导入任务状态 @@ -332,22 +335,22 @@ type FAQImportMetadata struct { // 这个信息是持久化的,不跟随进度状态,直到下次导入时被替换 type FAQImportResult struct { // 导入统计信息 - TotalEntries int `json:"total_entries"` // 总条目数 - SuccessCount int `json:"success_count"` // 成功导入的条目数 - FailedCount int `json:"failed_count"` // 失败的条目数 - SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等) - + TotalEntries int `json:"total_entries"` // 总条目数 + SuccessCount int `json:"success_count"` // 成功导入的条目数 + FailedCount int `json:"failed_count"` // 失败的条目数 + SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等) + // 导入模式和时间信息 - ImportMode string `json:"import_mode"` // 导入模式:append 或 replace - ImportedAt time.Time `json:"imported_at"` // 导入完成时间 - TaskID string `json:"task_id"` // 导入任务ID - + ImportMode string `json:"import_mode"` // 导入模式:append 或 replace + ImportedAt time.Time `json:"imported_at"` // 导入完成时间 + TaskID string `json:"task_id"` // 导入任务ID + // 失败详情URL(失败条目较多时提供下载链接) FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL - + // 显示控制 DisplayStatus string `json:"display_status"` // 显示状态:open 或 close - + // 额外统计信息 ProcessingTime int64 `json:"processing_time"` // 处理耗时(毫秒) } diff --git a/internal/types/interfaces/chunk.go b/internal/types/interfaces/chunk.go index b9d5568e..a6a16e0b 100644 --- a/internal/types/interfaces/chunk.go +++ b/internal/types/interfaces/chunk.go @@ -12,8 +12,12 @@ type ChunkRepository interface { CreateChunks(ctx context.Context, chunks []*types.Chunk) error // GetChunkByID gets a chunk by id GetChunkByID(ctx context.Context, tenantID uint64, id string) (*types.Chunk, error) + // GetChunkBySeqID gets a chunk by seq_id + GetChunkBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.Chunk, error) // ListChunksByID lists chunks by ids ListChunksByID(ctx context.Context, tenantID uint64, ids []string) ([]*types.Chunk, error) + // ListChunksBySeqID lists chunks by seq_ids + ListChunksBySeqID(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.Chunk, error) // ListChunksByKnowledgeID lists chunks by knowledge id ListChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error) // ListPagedChunksByKnowledgeID lists paged chunks by knowledge id. diff --git a/internal/types/interfaces/knowledge.go b/internal/types/interfaces/knowledge.go index 08326367..cc4ff585 100644 --- a/internal/types/interfaces/knowledge.go +++ b/internal/types/interfaces/knowledge.go @@ -73,14 +73,14 @@ type KnowledgeService interface { // UpdateImageInfo updates image information for a knowledge chunk. UpdateImageInfo(ctx context.Context, knowledgeID string, chunkID string, imageInfo string) error // ListFAQEntries lists FAQ entries under a FAQ knowledge base. - // When tagID is non-empty, results are filtered by tag_id on FAQ chunks. + // When tagSeqID is non-zero, results are filtered by tag seq_id on FAQ chunks. // searchField: specifies which field to search in ("standard_question", "similar_questions", "answers", "" for all) // sortOrder: "asc" for time ascending (updated_at ASC), default is time descending (updated_at DESC) ListFAQEntries( ctx context.Context, kbID string, page *types.Pagination, - tagID string, + tagSeqID int64, keyword string, searchField string, sortOrder string, @@ -91,15 +91,15 @@ type KnowledgeService interface { UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) (string, error) // CreateFAQEntry creates a single FAQ entry synchronously. CreateFAQEntry(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (*types.FAQEntry, error) - // GetFAQEntry retrieves a single FAQ entry by ID. - GetFAQEntry(ctx context.Context, kbID string, entryID string) (*types.FAQEntry, error) + // GetFAQEntry retrieves a single FAQ entry by seq_id. + GetFAQEntry(ctx context.Context, kbID string, entrySeqID int64) (*types.FAQEntry, error) // UpdateFAQEntry updates a single FAQ entry. - UpdateFAQEntry(ctx context.Context, kbID string, entryID string, payload *types.FAQEntryPayload) (*types.FAQEntry, error) + UpdateFAQEntry(ctx context.Context, kbID string, entrySeqID int64, payload *types.FAQEntryPayload) (*types.FAQEntry, error) // UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in batch. // Supports updating is_enabled, is_recommended, tag_id, and other fields in a single call. UpdateFAQEntryFieldsBatch(ctx context.Context, kbID string, req *types.FAQEntryFieldsBatchUpdate) error - // DeleteFAQEntries deletes FAQ entries in batch. - DeleteFAQEntries(ctx context.Context, kbID string, entryIDs []string) error + // DeleteFAQEntries deletes FAQ entries in batch by seq_id. + DeleteFAQEntries(ctx context.Context, kbID string, entrySeqIDs []int64) error // SearchFAQEntries searches FAQ entries using hybrid search. SearchFAQEntries(ctx context.Context, kbID string, req *types.FAQSearchRequest) ([]*types.FAQEntry, error) // ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data. @@ -107,7 +107,8 @@ type KnowledgeService interface { // UpdateKnowledgeTagBatch updates tag for document knowledge items in batch. UpdateKnowledgeTagBatch(ctx context.Context, updates map[string]*string) error // UpdateFAQEntryTagBatch updates tag for FAQ entries in batch. - UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[string]*string) error + // Key: entry seq_id, Value: tag seq_id (nil to remove tag) + UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error // GetRepository gets the knowledge repository GetRepository() KnowledgeRepository // ProcessDocument handles Asynq document processing tasks diff --git a/internal/types/interfaces/tag.go b/internal/types/interfaces/tag.go index 938ddda3..d8d94991 100644 --- a/internal/types/interfaces/tag.go +++ b/internal/types/interfaces/tag.go @@ -30,8 +30,12 @@ type KnowledgeTagRepository interface { Create(ctx context.Context, tag *types.KnowledgeTag) error Update(ctx context.Context, tag *types.KnowledgeTag) error GetByID(ctx context.Context, tenantID uint64, id string) (*types.KnowledgeTag, error) + // GetBySeqID retrieves a tag by its seq_id. + GetBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.KnowledgeTag, error) // GetByIDs retrieves multiple tags by their IDs in a single query. GetByIDs(ctx context.Context, tenantID uint64, ids []string) ([]*types.KnowledgeTag, error) + // GetBySeqIDs retrieves multiple tags by their seq_ids in a single query. + GetBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.KnowledgeTag, error) GetByName(ctx context.Context, tenantID uint64, kbID string, name string) (*types.KnowledgeTag, error) ListByKB( ctx context.Context, diff --git a/internal/types/tag.go b/internal/types/tag.go index c93e39b2..778f6bc8 100644 --- a/internal/types/tag.go +++ b/internal/types/tag.go @@ -8,6 +8,8 @@ import "time" type KnowledgeTag struct { // Unique identifier of the tag (UUID) ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + // SeqID is an auto-increment integer ID for external API usage + SeqID int64 `json:"seq_id" gorm:"type:bigint;uniqueIndex"` // Tenant ID TenantID uint64 `json:"tenant_id"` // Knowledge base ID that this tag belongs to diff --git a/migrations/mysql/000010_add_seq_id.down.sql b/migrations/mysql/000010_add_seq_id.down.sql new file mode 100644 index 00000000..b40e0dfa --- /dev/null +++ b/migrations/mysql/000010_add_seq_id.down.sql @@ -0,0 +1,5 @@ +-- Migration 000010: Remove seq_id from chunks and knowledge_tags tables +-- MySQL version + +ALTER TABLE chunks DROP COLUMN seq_id; +ALTER TABLE knowledge_tags DROP COLUMN seq_id; diff --git a/migrations/mysql/000010_add_seq_id.up.sql b/migrations/mysql/000010_add_seq_id.up.sql new file mode 100644 index 00000000..edb1a944 --- /dev/null +++ b/migrations/mysql/000010_add_seq_id.up.sql @@ -0,0 +1,29 @@ +-- Migration 000010: Add seq_id (auto-increment integer ID) to chunks and knowledge_tags tables +-- This provides integer IDs for FAQ entries and tags for external API usage +-- MySQL version + +-- ============================================================================ +-- Section 1: Add seq_id to chunks table +-- ============================================================================ + +-- Add seq_id column with AUTO_INCREMENT (historical data starts from 1) +ALTER TABLE chunks ADD COLUMN seq_id BIGINT NOT NULL AUTO_INCREMENT UNIQUE KEY; + +-- Update historical data to start from 100000000 +UPDATE chunks SET seq_id = seq_id + 99999999; + +-- Set AUTO_INCREMENT for future inserts (must be greater than max seq_id) +ALTER TABLE chunks AUTO_INCREMENT = 200000000; + +-- ============================================================================ +-- Section 2: Add seq_id to knowledge_tags table +-- ============================================================================ + +-- Add seq_id column with AUTO_INCREMENT (historical data starts from 1) +ALTER TABLE knowledge_tags ADD COLUMN seq_id BIGINT NOT NULL AUTO_INCREMENT UNIQUE KEY; + +-- Update historical data to start from 10000000 +UPDATE knowledge_tags SET seq_id = seq_id + 9999999; + +-- Set AUTO_INCREMENT for future inserts (must be greater than max seq_id) +ALTER TABLE knowledge_tags AUTO_INCREMENT = 20000000; diff --git a/migrations/versioned/000010_add_seq_id.down.sql b/migrations/versioned/000010_add_seq_id.down.sql new file mode 100644 index 00000000..afec107e --- /dev/null +++ b/migrations/versioned/000010_add_seq_id.down.sql @@ -0,0 +1,11 @@ +-- Migration 000010 Down: Remove seq_id from chunks and knowledge_tags tables + +-- Remove seq_id from chunks +DROP INDEX IF EXISTS idx_chunks_seq_id; +ALTER TABLE chunks DROP COLUMN IF EXISTS seq_id; +DROP SEQUENCE IF EXISTS chunks_seq_id_seq; + +-- Remove seq_id from knowledge_tags +DROP INDEX IF EXISTS idx_knowledge_tags_seq_id; +ALTER TABLE knowledge_tags DROP COLUMN IF EXISTS seq_id; +DROP SEQUENCE IF EXISTS knowledge_tags_seq_id_seq; diff --git a/migrations/versioned/000010_add_seq_id.up.sql b/migrations/versioned/000010_add_seq_id.up.sql new file mode 100644 index 00000000..ce11ce9c --- /dev/null +++ b/migrations/versioned/000010_add_seq_id.up.sql @@ -0,0 +1,52 @@ +-- Migration 000010: Add seq_id (auto-increment integer ID) to chunks and knowledge_tags tables +-- This provides integer IDs for FAQ entries and tags for external API usage + +-- ============================================================================ +-- Section 1: Add seq_id to chunks table +-- ============================================================================ + +DO $$ BEGIN RAISE NOTICE '[Migration 000010] Adding seq_id column to chunks table'; END $$; + +-- Create sequence for chunks with starting value > 72528124 +CREATE SEQUENCE IF NOT EXISTS chunks_seq_id_seq START WITH 100000000; + +-- Add seq_id column to chunks table +ALTER TABLE chunks ADD COLUMN IF NOT EXISTS seq_id BIGINT; + +-- Set default value using sequence +ALTER TABLE chunks ALTER COLUMN seq_id SET DEFAULT nextval('chunks_seq_id_seq'); + +-- Populate existing rows with sequence values +UPDATE chunks SET seq_id = nextval('chunks_seq_id_seq') WHERE seq_id IS NULL; + +-- Make seq_id NOT NULL after populating +ALTER TABLE chunks ALTER COLUMN seq_id SET NOT NULL; + +-- Create unique index on seq_id +CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_seq_id ON chunks(seq_id); + +-- ============================================================================ +-- Section 2: Add seq_id to knowledge_tags table +-- ============================================================================ + +DO $$ BEGIN RAISE NOTICE '[Migration 000010] Adding seq_id column to knowledge_tags table'; END $$; + +-- Create sequence for knowledge_tags with starting value > 2924026 +CREATE SEQUENCE IF NOT EXISTS knowledge_tags_seq_id_seq START WITH 10000000; + +-- Add seq_id column to knowledge_tags table +ALTER TABLE knowledge_tags ADD COLUMN IF NOT EXISTS seq_id BIGINT; + +-- Set default value using sequence +ALTER TABLE knowledge_tags ALTER COLUMN seq_id SET DEFAULT nextval('knowledge_tags_seq_id_seq'); + +-- Populate existing rows with sequence values +UPDATE knowledge_tags SET seq_id = nextval('knowledge_tags_seq_id_seq') WHERE seq_id IS NULL; + +-- Make seq_id NOT NULL after populating +ALTER TABLE knowledge_tags ALTER COLUMN seq_id SET NOT NULL; + +-- Create unique index on seq_id +CREATE UNIQUE INDEX IF NOT EXISTS idx_knowledge_tags_seq_id ON knowledge_tags(seq_id); + +DO $$ BEGIN RAISE NOTICE '[Migration 000010] seq_id columns added successfully!'; END $$;