diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index ccf8245..f020405 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -242,6 +242,11 @@ async function switchMenu(menu: string) { await loadCurrentMenu(); } +async function previewKnowledge(knowledgeId: number) { + await switchMenu("prompt"); + agentForm.knowledgeIds = [knowledgeId]; +} + async function loadDashboard() { stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end); } @@ -955,7 +960,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { - + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue index 95dda46..220b7e2 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/KnowledgeManagementView.vue @@ -12,6 +12,8 @@ import type { MigrationSummary, } from "../types/api"; +const emit = defineEmits<{ preview: [knowledgeId: number] }>(); + const rows = ref([]); const migration = ref(null); const loading = ref(false); @@ -22,6 +24,11 @@ const versions = ref([]); const jobs = ref([]); const versionContent = ref(null); const activeTab = ref("overview"); +const selectedRows = ref([]); +const editOpen = ref(false); +const editSaving = ref(false); +const batchOpen = ref(false); +const batchSaving = ref(false); const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" }); const form = reactive({ name: "", @@ -32,6 +39,8 @@ const form = reactive({ status: 0, remark: "", }); +const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" }); +const batchForm = reactive({ knowledgeType: "", reviewMode: "" }); const filteredRows = computed(() => rows.value.filter((item) => { if (filter.keyword && !`${item.name}${item.sourceTitle || ""}`.includes(filter.keyword)) return false; @@ -49,27 +58,131 @@ async function load() { loading.value = true; try { [rows.value, migration.value] = await Promise.all([api.knowledge(), api.migrationSummary()]); + } catch (error) { + ElMessage.error(errorMessage(error, "知识库列表加载失败")); } finally { loading.value = false; } } +function openEdit() { + if (!detail.value) return; + Object.assign(editForm, { + id: detail.value.knowledge.id, + name: detail.value.knowledge.name, + knowledgeType: detail.value.knowledge.knowledgeType, + reviewMode: detail.value.knowledge.reviewMode, + }); + editOpen.value = true; +} + +async function saveMetadata() { + if (!editForm.name.trim()) { + ElMessage.warning("请输入后台知识库名称"); + return; + } + editSaving.value = true; + try { + await api.updateKnowledgeMetadata(editForm.id, { + name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode, + }); + ElMessage.success("知识库基本信息已更新"); + editOpen.value = false; + await Promise.all([load(), refreshDetail(editForm.id)]); + } catch (error) { + ElMessage.error(errorMessage(error, "基本信息保存失败")); + } finally { + editSaving.value = false; + } +} + +function openBatchEdit() { + if (!selectedRows.value.length) { + ElMessage.warning("请先勾选需要修改的知识库"); + return; + } + Object.assign(batchForm, { knowledgeType: "", reviewMode: "" }); + batchOpen.value = true; +} + +async function saveBatchMetadata() { + if (!batchForm.knowledgeType && !batchForm.reviewMode) { + ElMessage.warning("请至少选择一个修改项"); + return; + } + if (!await confirmAction( + `将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`, + "确认批量修改", + { confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" }, + )) return; + batchSaving.value = true; + try { + const result = await api.batchUpdateKnowledgeMetadata({ + knowledgeIds: selectedRows.value.map((item) => item.id), + knowledgeType: batchForm.knowledgeType || undefined, + reviewMode: batchForm.reviewMode || undefined, + }); + ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`); + batchOpen.value = false; + await load(); + } catch (error) { + ElMessage.error(errorMessage(error, "批量修改失败")); + } finally { + batchSaving.value = false; + } +} + +async function handleMore(command: string, item: KnowledgeItem) { + try { + if (command === "detail") return await openDetail(item); + if (command === "preview") return emit("preview", item.id); + if (command === "versions") { + await openDetail(item); + if (detailOpen.value) activeTab.value = "versions"; + return; + } + if (command === "archive") return await archive(item); + if (command === "restore") return await restore(item); + if (command === "delete") return await remove(item); + } catch (error) { + if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败")); + } +} + +async function restore(item: KnowledgeItem) { + if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", { + confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning", + })) return; + await api.setKnowledgeLifecycle(item.id, "active"); + ElMessage.success("知识库已恢复,当前仍为关闭状态"); + await load(); +} + async function createKnowledge() { if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) { ElMessage.warning("请填写名称、SpaceID 和 NodeID"); return; } - await api.createKnowledge({ ...form }); - Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" }); - ElMessage.success("知识库已新增,请进行首次同步"); - await load(); + try { + await api.createKnowledge({ ...form }); + Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" }); + ElMessage.success("知识库已新增,请进行首次同步"); + await load(); + } catch (error) { + ElMessage.error(errorMessage(error, "知识库新增失败")); + } } async function openDetail(item: KnowledgeItem) { detailOpen.value = true; activeTab.value = "overview"; versionContent.value = null; - await refreshDetail(item.id); + try { + await refreshDetail(item.id); + } catch (error) { + detailOpen.value = false; + ElMessage.error(errorMessage(error, "知识库详情加载失败")); + } } async function refreshDetail(id: number) { @@ -81,26 +194,35 @@ async function refreshDetail(id: number) { async function sync(item: KnowledgeItem, mode: "review" | "replace") { operatingId.value = item.id; try { - await api.syncKnowledge(item.id, mode); - ElMessage.success("同步处理完成,已生成待审核版本"); + const result = await api.syncKnowledge(item.id, mode); + const message = result.status === "unchanged" + ? "飞书内容未变化,未生成新版本" + : result.status === "published" + ? "同步、质检和自动发布已完成" + : "同步处理完成,已生成待审核版本"; + ElMessage.success(message); await load(); if (detailOpen.value) await refreshDetail(item.id); + } catch (error) { + ElMessage.error(errorMessage(error, "知识库同步失败")); } finally { operatingId.value = null; } } async function runMigration() { - await ElMessageBox.confirm( + if (!await confirmAction( "将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。", "开始知识迁移", { confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" }, - ); + )) return; loading.value = true; try { const result = await api.runKnowledgeMigration(); ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`); await load(); + } catch (error) { + ElMessage.error(errorMessage(error, "知识迁移失败")); } finally { loading.value = false; } @@ -108,80 +230,104 @@ async function runMigration() { async function confirmManifest() { if (!detail.value?.pendingVersion) return; - await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id); - ElMessage.success("知识库说明卡已确认"); - await refreshDetail(detail.value.knowledge.id); + try { + await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id); + ElMessage.success("知识库说明卡已确认"); + await refreshDetail(detail.value.knowledge.id); + } catch (error) { + ElMessage.error(errorMessage(error, "说明卡确认失败")); + } } async function review(action: "approve" | "reject") { if (!detail.value?.pendingVersion) return; let reason = ""; if (action === "reject") { - const result = await ElMessageBox.prompt( - "请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。", - "拒绝版本", - { confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" }, - ); - reason = result.value; + try { + const result = await ElMessageBox.prompt( + "请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。", + "拒绝版本", + { confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" }, + ); + reason = result.value; + } catch { + return; + } + } + try { + await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason); + ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝"); + await refreshDetail(detail.value.knowledge.id); + } catch (error) { + ElMessage.error(errorMessage(error, "版本审核失败")); } - await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason); - ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝"); - await refreshDetail(detail.value.knowledge.id); } async function publish(keepOpen: boolean) { if (!detail.value?.pendingVersion) return; - await ElMessageBox.confirm( + if (!await confirmAction( `将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}。`, "发布知识版本", { confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" }, - ); - await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen); - ElMessage.success("正式版本已发布"); - await load(); - await refreshDetail(detail.value.knowledge.id); + )) return; + try { + await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen); + ElMessage.success("正式版本已发布"); + await load(); + await refreshDetail(detail.value.knowledge.id); + } catch (error) { + ElMessage.error(errorMessage(error, "版本发布失败")); + } } async function rollback(version: KnowledgeVersion) { if (!detail.value) return; - await ElMessageBox.confirm( + if (!await confirmAction( `将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`, "回滚知识版本", { confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" }, - ); - await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1); - ElMessage.success("知识版本已回滚"); - await load(); - await refreshDetail(detail.value.knowledge.id); + )) return; + try { + await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1); + ElMessage.success("知识版本已回滚"); + await load(); + await refreshDetail(detail.value.knowledge.id); + } catch (error) { + ElMessage.error(errorMessage(error, "版本回滚失败")); + } } async function toggleOpen(item: KnowledgeItem) { const next = item.status !== 1; - await ElMessageBox.confirm( + if (!await confirmAction( next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。", next ? "开放知识库" : "关闭知识库", { confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" }, - ); - await api.setKnowledgeOpen(item.id, next); - ElMessage.success(next ? "知识库已开放" : "知识库已关闭"); - await load(); + )) return; + try { + await api.setKnowledgeOpen(item.id, next); + ElMessage.success(next ? "知识库已开放" : "知识库已关闭"); + await load(); + } catch (error) { + ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败")); + } } async function archive(item: KnowledgeItem) { - await ElMessageBox.confirm("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", { + if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", { confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning", - }); + })) return; await api.setKnowledgeLifecycle(item.id, "archived"); ElMessage.success("知识库已归档"); await load(); } async function remove(item: KnowledgeItem) { - await ElMessageBox.confirm( + if (!await confirmAction( `将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`, "物理删除知识库", { confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" }, - ); + )) return; await api.deleteKnowledge(item.id); ElMessage.success("知识库已删除"); await load(); @@ -202,6 +348,23 @@ function statusType(status: string) { function typeLabel(value: string) { return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value; } + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +async function confirmAction( + message: string, + title: string, + options: Parameters[2], +) { + try { + await ElMessageBox.confirm(message, title, options); + return true; + } catch { + return false; + } +} @@ -242,13 +405,19 @@ function typeLabel(value: string) { - + + 已选择 {{ selectedRows.length }} 个知识库 + 批量修改类型/审核方式 + + + + {{ row.name }}{{ row.sourceTitle || "尚未同步源标题" }} {{ typeLabel(row.knowledgeType) }}{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }} 正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }} {{ row.status === 1 ? '已开放' : '已关闭' }}{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }} - 详情同步{{ row.status === 1 ? '关闭' : '开放' }}更多同步并准备替换归档物理删除 + 详情同步{{ row.status === 1 ? '关闭' : '开放' }}更多查看详情进入 Agent 预览查看版本归档恢复删除 @@ -260,9 +429,12 @@ function typeLabel(value: string) { - SpaceID{{ detail.knowledge.feishuSpaceId }}NodeID{{ detail.knowledge.feishuNodeId }}正式版本{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}待发布版本{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }} + 基本信息编辑基本信息 + 后台名称{{ detail.knowledge.name }}知识库类型{{ typeLabel(detail.knowledge.knowledgeType) }}审核方式{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}SpaceID{{ detail.knowledge.feishuSpaceId }}NodeID{{ detail.knowledge.feishuNodeId }}正式版本{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}待发布版本{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }} Knowledge Manifest用途:{{ detail.manifest.purpose }}适用:{{ detail.manifest.applicableQuestions }}不适用:{{ detail.manifest.inapplicableQuestions }}核心主题:{{ detail.manifest.coreTopics }}边界:{{ detail.manifest.boundaries }}确认说明卡 - 审核通过审核拒绝发布并保持开放发布后关闭 + 审核通过审核拒绝 + 发布并保持开放发布后关闭 + @@ -273,4 +445,47 @@ function typeLabel(value: string) { + + + + + + + + + 取消保存 + + + + + + + + + 取消下一步 + + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index ae2739b..85f6b00 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -14,6 +14,7 @@ import type { KnowledgeVersion, KnowledgeVersionContent, MigrationSummary, + KnowledgeBatchUpdateResult, RetrievalLogItem, AttentionItem, ModelItem, @@ -42,9 +43,9 @@ async function request(path: string, init: RequestInit = {}): Promise { const token = getToken(); if (token) headers.set("Authorization", `Bearer ${token}`); const response = await fetch(`${API_BASE}${path}`, { ...init, headers }); - const body = (await response.json()) as ApiResponse; + const body = (await response.json()) as ApiResponse & { detail?: string }; if (!response.ok || body.code !== 0) { - throw new Error(body.message || `${response.status} ${response.statusText}`); + throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`); } return body.data; } @@ -119,6 +120,10 @@ export const api = { request("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }), updateKnowledge: (id: number, payload: Record) => request(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }), + updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) => + request(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }), + batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) => + request("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }), deleteKnowledge: (id: number) => request(`/admin/knowledge/${id}`, { method: "DELETE" }), importKnowledgeFromSpace: (body: { node_token: string }) => request<{ imported: number; skipped: number; id: number; name: string }>( diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index 7013c00..47acf89 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -178,6 +178,13 @@ export interface MigrationSummary { items: KnowledgeItem[]; } +export interface KnowledgeBatchUpdateResult { + total: number; + success: number; + failed: number; + results: Array<{ knowledgeId: number; ok: boolean; error?: string }>; +} + export interface RetrievalLogItem { id: number; sessionId?: number | null; diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0008_unique_knowledge_feishu_node.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0008_unique_knowledge_feishu_node.py new file mode 100644 index 0000000..06ec548 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0008_unique_knowledge_feishu_node.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from alembic import op + +revision = "0008_unique_knowledge_feishu_node" +down_revision = "0007_knowledge_agent_rebuild" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_unique_constraint( + "uq_knowledge_feishu_node", + "sys_knowledge", + ["feishu_space_id", "feishu_node_id"], + ) + + +def downgrade() -> None: + op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py index 451c22a..bb1342c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py @@ -2,6 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.database import get_db @@ -24,6 +25,7 @@ from app.models.knowledge import ( from app.schemas.admin import KnowledgeSaveRequest from app.services.admin_service import OperationLogService from app.services.feishu_service import FeishuKnowledgeService +from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService router = APIRouter() @@ -40,6 +42,7 @@ def import_knowledge_from_space( db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: + _require_super_admin(current_admin) node_token = payload.get("node_token", "").strip() if not node_token: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空") @@ -61,7 +64,7 @@ def import_knowledge_from_space( name=info.get("title", node_token), feishu_space_id=space_id, feishu_node_id=node_token, - status=1, + status=0, knowledge_type="general", review_mode="manual", remark=f"obj_type={info.get('obj_type', '')}", @@ -71,8 +74,13 @@ def import_knowledge_from_space( OperationLogService.write( db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id ) - db.commit() + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc db.refresh(knowledge) + KnowledgeCatalogCacheService.invalidate() return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name}) @@ -82,11 +90,20 @@ def create_knowledge( db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: + _require_super_admin(current_admin) + existing = db.scalar( + select(Knowledge).where( + Knowledge.feishu_space_id == payload.feishuSpaceId, + Knowledge.feishu_node_id == payload.feishuNodeId, + ) + ) + if existing is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") knowledge = Knowledge( name=payload.name, feishu_space_id=payload.feishuSpaceId, feishu_node_id=payload.feishuNodeId, - status=payload.status, + status=0, remark=payload.remark, knowledge_type=payload.knowledgeType, review_mode=payload.reviewMode, @@ -94,8 +111,13 @@ def create_knowledge( db.add(knowledge) db.flush() OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id) - db.commit() + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc db.refresh(knowledge) + KnowledgeCatalogCacheService.invalidate() return api_success(_knowledge_dict(knowledge)) @@ -106,11 +128,9 @@ def update_knowledge( db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: + _require_super_admin(current_admin) knowledge = _get_knowledge(db, knowledge_id) knowledge.name = payload.name - knowledge.feishu_space_id = payload.feishuSpaceId - knowledge.feishu_node_id = payload.feishuNodeId - knowledge.status = payload.status knowledge.remark = payload.remark knowledge.knowledge_type = payload.knowledgeType knowledge.review_mode = payload.reviewMode @@ -118,6 +138,7 @@ def update_knowledge( OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id) db.commit() db.refresh(knowledge) + KnowledgeCatalogCacheService.invalidate() return api_success(_knowledge_dict(knowledge)) @@ -127,7 +148,13 @@ def delete_knowledge( db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: + _require_super_admin(current_admin) knowledge = _get_knowledge(db, knowledge_id) + if knowledge.lifecycle_status != "archived" or knowledge.status != 0: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="物理删除前必须先关闭并归档知识库", + ) # 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。 permissions = db.scalars( select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id) @@ -146,6 +173,7 @@ def delete_knowledge( db.delete(knowledge) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id) db.commit() + KnowledgeCatalogCacheService.invalidate() return api_success() @@ -156,6 +184,11 @@ def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge: return knowledge +def _require_super_admin(admin: Admin) -> None: + if admin.role is not None and admin.role.code != "super_admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以管理知识库") + + def _knowledge_dict(knowledge: Knowledge) -> dict: return { "id": knowledge.id, diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge_lifecycle.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge_lifecycle.py index d809e24..f609736 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge_lifecycle.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge_lifecycle.py @@ -21,6 +21,8 @@ from app.models.knowledge import ( ) from app.schemas.knowledge import ( KnowledgeLifecycleRequest, + KnowledgeBatchMetadataUpdateRequest, + KnowledgeMetadataUpdateRequest, KnowledgePublishRequest, KnowledgeReviewRequest, KnowledgeStatusRequest, @@ -28,10 +30,74 @@ from app.schemas.knowledge import ( ) from app.services.admin_service import OperationLogService from app.services.knowledge_pipeline_service import KnowledgePipelineService +from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService router = APIRouter() +@router.patch("/knowledge/batch/metadata") +def batch_update_metadata( + payload: KnowledgeBatchMetadataUpdateRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + _require_super_admin(current_admin) + if payload.knowledgeType is None and payload.reviewMode is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少选择一个待修改字段") + knowledge_ids = list(dict.fromkeys(payload.knowledgeIds)) + rows = { + item.id: item + for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all() + } + results: list[dict] = [] + for knowledge_id in knowledge_ids: + item = rows.get(knowledge_id) + if item is None: + results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"}) + continue + if payload.knowledgeType is not None: + item.knowledge_type = payload.knowledgeType + if payload.reviewMode is not None: + item.review_mode = payload.reviewMode + db.add(item) + OperationLogService.write( + db, + admin_id=current_admin.id, + module="knowledge", + action="batch_update_metadata", + target_id=item.id, + ) + results.append({"knowledgeId": knowledge_id, "ok": True}) + db.commit() + KnowledgeCatalogCacheService.invalidate() + success = sum(1 for item in results if item["ok"]) + return api_success( + {"total": len(results), "success": success, "failed": len(results) - success, "results": results} + ) + + +@router.patch("/knowledge/{knowledge_id}/metadata") +def update_metadata( + knowledge_id: int, + payload: KnowledgeMetadataUpdateRequest, + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + _require_super_admin(current_admin) + knowledge = _knowledge(db, knowledge_id) + knowledge.name = payload.name.strip() + knowledge.knowledge_type = payload.knowledgeType + knowledge.review_mode = payload.reviewMode + db.add(knowledge) + OperationLogService.write( + db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id + ) + db.commit() + db.refresh(knowledge) + KnowledgeCatalogCacheService.invalidate() + return api_success(_knowledge_detail(knowledge)) + + @router.get("/knowledge/{knowledge_id}/detail") def detail( knowledge_id: int, @@ -159,6 +225,7 @@ async def synchronize( raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id) db.commit() + KnowledgeCatalogCacheService.invalidate() return api_success(_job_dict(job)) @@ -171,6 +238,8 @@ def confirm_manifest( ) -> dict: _require_super_admin(current_admin) knowledge, version = _knowledge_version(db, knowledge_id, version_id) + if knowledge.pending_version_id != version.id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡") try: KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id) except ValueError as exc: @@ -190,6 +259,8 @@ def review( ) -> dict: _require_super_admin(current_admin) knowledge, version = _knowledge_version(db, knowledge_id, version_id) + if knowledge.pending_version_id != version.id or version.status != "pending_review": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本") if version.quality_status == "blocked" and payload.action == "approve": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过") KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason) @@ -208,12 +279,15 @@ def publish( ) -> dict: _require_super_admin(current_admin) knowledge, version = _knowledge_version(db, knowledge_id, version_id) + if knowledge.pending_version_id != version.id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本") try: KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen) except ValueError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id) db.commit() + KnowledgeCatalogCacheService.invalidate() return api_success(_knowledge_detail(knowledge)) @@ -227,12 +301,17 @@ def rollback( ) -> dict: _require_super_admin(current_admin) knowledge, version = _knowledge_version(db, knowledge_id, version_id) + if knowledge.current_version_id == version.id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本") + if payload.keepOpen and knowledge.lifecycle_status != "active": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放") try: KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen) except ValueError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id) db.commit() + KnowledgeCatalogCacheService.invalidate() return api_success(_knowledge_detail(knowledge)) @@ -258,6 +337,7 @@ def open_status( db.add(knowledge) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id) db.commit() + KnowledgeCatalogCacheService.invalidate() return api_success(_knowledge_detail(knowledge)) @@ -270,12 +350,15 @@ def lifecycle( ) -> dict: _require_super_admin(current_admin) knowledge = _knowledge(db, knowledge_id) + if knowledge.lifecycle_status == payload.status: + return api_success(_knowledge_detail(knowledge)) knowledge.lifecycle_status = payload.status if payload.status == "archived": knowledge.status = 0 db.add(knowledge) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id) db.commit() + KnowledgeCatalogCacheService.invalidate() return api_success(_knowledge_detail(knowledge)) @@ -347,6 +430,8 @@ def _knowledge_detail(item: Knowledge) -> dict: return { "id": item.id, "name": item.name, + "status": item.status, + "remark": item.remark, "sourceTitle": item.source_title, "feishuSpaceId": item.feishu_space_id, "feishuNodeId": item.feishu_node_id, @@ -362,6 +447,8 @@ def _knowledge_detail(item: Knowledge) -> dict: "lastSyncedAt": item.last_synced_at, "lastPublishedAt": item.last_published_at, "migrationStatus": item.migration_status, + "createdAt": item.created_at, + "updatedAt": item.updated_at, } diff --git a/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py b/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py index 6b504e3..4e461a2 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/knowledge.py @@ -12,6 +12,7 @@ PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite") class Knowledge(Base, TimestampMixin): __tablename__ = "sys_knowledge" + __table_args__ = (UniqueConstraint("feishu_space_id", "feishu_node_id", name="uq_knowledge_feishu_node"),) id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(100), nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/models/logs.py b/ai_knowledge_base_v2/apps/backend/app/models/logs.py index a0ebfa4..0ce3e8f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/logs.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/logs.py @@ -7,11 +7,13 @@ from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base +PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite") + class AiRequestLog(Base): __tablename__ = "sys_ai_request_log" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) @@ -32,7 +34,7 @@ class AiRequestLog(Base): class OperationLog(Base): __tablename__ = "sys_operation_log" - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True) admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True) module: Mapped[str] = mapped_column(String(50), nullable=False) action: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py b/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py index c4344bd..a1bc43c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py @@ -24,6 +24,18 @@ class KnowledgeLifecycleRequest(BaseModel): status: str = Field(pattern="^(active|archived)$") +class KnowledgeMetadataUpdateRequest(BaseModel): + name: str = Field(min_length=1, max_length=100) + knowledgeType: str = Field(pattern="^(course|qa|general)$") + reviewMode: str = Field(pattern="^(manual|automatic)$") + + +class KnowledgeBatchMetadataUpdateRequest(BaseModel): + knowledgeIds: list[int] = Field(min_length=1, max_length=500) + knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$") + reviewMode: str | None = Field(default=None, pattern="^(manual|automatic)$") + + class AttentionUpdateRequest(BaseModel): status: str = Field(pattern="^(pending|processing|resolved|ignored)$") note: str | None = Field(default=None, max_length=2000) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_catalog_cache_service.py b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_catalog_cache_service.py new file mode 100644 index 0000000..2d64f64 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_catalog_cache_service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from app.services.redis_client import get_sync_redis_client + + +class KnowledgeCatalogCacheService: + """Central invalidation point for current and future KnowledgeList caches.""" + + VERSION_KEY = "knowledge:catalog:version" + + @classmethod + def invalidate(cls) -> None: + client = get_sync_redis_client() + if client is None: + return + try: + client.incr(cls.VERSION_KEY) + except Exception: + # The catalog currently reads MySQL on every request, so Redis failure + # must not make a committed metadata change look unsuccessful. + return diff --git a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_pipeline_service.py b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_pipeline_service.py index 26697fc..18df29f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_pipeline_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_pipeline_service.py @@ -210,7 +210,7 @@ class KnowledgePipelineService: boundaries=manifest_data["boundaries"], content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)), change_level=change_level, - confirmed=1 if previous_manifest and change_level == "minor" else 0, + confirmed=1 if previous_manifest and previous_manifest.confirmed == 1 and change_level == "minor" else 0, ) db.add(manifest) @@ -236,6 +236,26 @@ class KnowledgePipelineService: {"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False ) job.finished_at = _now() + if ( + knowledge.review_mode == "automatic" + and version.quality_status == "passed" + and not quality["highRisk"] + and manifest.confirmed == 1 + ): + cls.review_version( + db, + knowledge, + version, + admin_id, + "approve", + "自动审核方式:质检通过且说明卡可继承,自动发布。", + ) + cls.publish_version(db, knowledge, version, admin_id, keep_open=knowledge.status == 1) + job.status = "published" + job.result_summary = json.dumps( + {"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality}, + ensure_ascii=False, + ) db.add_all([knowledge, version, job]) db.commit() return job @@ -299,6 +319,10 @@ class KnowledgePipelineService: manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id)) if manifest is None or manifest.confirmed != 1: raise ValueError("知识库说明卡尚未确认") + if keep_open and knowledge.lifecycle_status != "active": + raise ValueError("归档知识库不能在发布后开放") + if keep_open and knowledge.source_status != "normal": + raise ValueError("飞书源文章异常,不能在发布后开放") previous_version_id = knowledge.current_version_id if previous_version_id: previous = db.get(KnowledgeVersion, previous_version_id) @@ -334,12 +358,19 @@ class KnowledgePipelineService: if version.status not in {"published", "superseded"}: raise ValueError("只能回滚到历史正式版本") previous_id = knowledge.current_version_id + if previous_id: + previous = db.get(KnowledgeVersion, previous_id) + if previous: + previous.status = "superseded" + db.add(previous) + version.status = "published" knowledge.current_version_id = version.id knowledge.status = 1 if keep_open else 0 knowledge.last_published_at = _now() db.add_all( [ knowledge, + version, KnowledgePublishLog( knowledge_id=knowledge.id, from_version_id=previous_id, diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_admin.py b/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_admin.py new file mode 100644 index 0000000..928103c --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_knowledge_admin.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.api.admin_knowledge_lifecycle import batch_update_metadata, update_metadata +from app.models import Base +from app.models.admin import Admin, Role +from app.models.knowledge import Knowledge, KnowledgeManifest, KnowledgeSourceSnapshot, KnowledgeVersion +from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest +from app.services.knowledge_agent_service import KnowledgeAgentService +from app.services.knowledge_pipeline_service import KnowledgePipelineService + + +def _database() -> Session: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + return Session(engine) + + +def _admin(role_code: str | None = None) -> Admin: + role = Role(id=10, code=role_code, name=role_code) if role_code else None + return Admin(id=1, username="admin", password="hash", name="管理员", status=1, role=role) + + +def _knowledge(db: Session, knowledge_id: int = 1) -> Knowledge: + item = Knowledge( + id=knowledge_id, + name="原名称", + feishu_space_id="space-1", + feishu_node_id="node-1", + status=1, + knowledge_type="general", + review_mode="manual", + source_status="normal", + manifest_confirmed=1, + ) + db.add(item) + db.flush() + snapshot = KnowledgeSourceSnapshot( + knowledge_id=item.id, + source_title="源标题", + source_content="源内容", + content_hash=f"hash-{knowledge_id}", + source_identifier="space-1/node-1", + ) + db.add(snapshot) + db.flush() + version = KnowledgeVersion( + knowledge_id=item.id, + snapshot_id=snapshot.id, + version_no=1, + status="published", + quality_status="passed", + processing_rule_version="test-v1", + ) + db.add(version) + db.flush() + item.current_version_id = version.id + db.add( + KnowledgeManifest( + knowledge_id=item.id, + version_id=version.id, + purpose="原用途", + applicable_questions="课程问题", + inapplicable_questions="天气", + core_topics="课程", + content_hash=f"manifest-{knowledge_id}", + confirmed=1, + ) + ) + db.commit() + return item + + +def test_metadata_update_preserves_source_version_and_open_state(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("app.api.admin_knowledge_lifecycle.KnowledgeCatalogCacheService.invalidate", lambda: None) + with _database() as db: + item = _knowledge(db) + original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status) + update_metadata( + item.id, + KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"), + db, + _admin(), + ) + db.refresh(item) + assert (item.name, item.knowledge_type, item.review_mode) == ("新后台名称", "course", "automatic") + assert (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status) == original + assert KnowledgeAgentService.get_knowledge_catalog(db)[0]["name"] == "新后台名称" + + +def test_metadata_update_requires_super_admin(): + with _database() as db: + item = _knowledge(db) + with pytest.raises(HTTPException) as exc: + update_metadata( + item.id, + KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"), + db, + _admin("operator"), + ) + assert exc.value.status_code == 403 + + +def test_batch_metadata_update_reports_missing_items(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("app.api.admin_knowledge_lifecycle.KnowledgeCatalogCacheService.invalidate", lambda: None) + with _database() as db: + item = _knowledge(db) + response = batch_update_metadata( + KnowledgeBatchMetadataUpdateRequest(knowledgeIds=[item.id, 999], knowledgeType="qa"), + db, + _admin(), + ) + assert response["data"]["success"] == 1 + assert response["data"]["failed"] == 1 + assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa" + + +def test_rollback_switches_published_version_statuses(): + with _database() as db: + item = _knowledge(db) + old_version = db.get(KnowledgeVersion, item.current_version_id) + snapshot = db.scalar(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id)) + target = KnowledgeVersion( + knowledge_id=item.id, + snapshot_id=snapshot.id, + version_no=0, + status="superseded", + quality_status="passed", + processing_rule_version="test-v1", + ) + db.add(target) + db.flush() + KnowledgePipelineService.rollback(db, item, target, admin_id=1, keep_open=True) + db.commit() + assert item.current_version_id == target.id + assert target.status == "published" + assert old_version.status == "superseded"
用途:{{ detail.manifest.purpose }}
适用:{{ detail.manifest.applicableQuestions }}
不适用:{{ detail.manifest.inapplicableQuestions }}
核心主题:{{ detail.manifest.coreTopics }}
边界:{{ detail.manifest.boundaries }}