fix: 补齐知识库元数据管理与生命周期校验

This commit is contained in:
2026-07-12 15:30:23 +08:00
parent c3639fe30d
commit 8266d845c0
13 changed files with 640 additions and 59 deletions

View File

@@ -242,6 +242,11 @@ async function switchMenu(menu: string) {
await loadCurrentMenu(); await loadCurrentMenu();
} }
async function previewKnowledge(knowledgeId: number) {
await switchMenu("prompt");
agentForm.knowledgeIds = [knowledgeId];
}
async function loadDashboard() { async function loadDashboard() {
stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end); stats.value = await api.dashboard(dashboardFilters.start, dashboardFilters.end);
} }
@@ -955,7 +960,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</template> </template>
<template v-if="activeMenu === 'knowledge'"> <template v-if="activeMenu === 'knowledge'">
<KnowledgeManagementView /> <KnowledgeManagementView @preview="previewKnowledge" />
</template> </template>
<template v-if="activeMenu === 'prompt'"> <template v-if="activeMenu === 'prompt'">

View File

@@ -12,6 +12,8 @@ import type {
MigrationSummary, MigrationSummary,
} from "../types/api"; } from "../types/api";
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
const rows = ref<KnowledgeItem[]>([]); const rows = ref<KnowledgeItem[]>([]);
const migration = ref<MigrationSummary | null>(null); const migration = ref<MigrationSummary | null>(null);
const loading = ref(false); const loading = ref(false);
@@ -22,6 +24,11 @@ const versions = ref<KnowledgeVersion[]>([]);
const jobs = ref<KnowledgeSyncJob[]>([]); const jobs = ref<KnowledgeSyncJob[]>([]);
const versionContent = ref<KnowledgeVersionContent | null>(null); const versionContent = ref<KnowledgeVersionContent | null>(null);
const activeTab = ref("overview"); const activeTab = ref("overview");
const selectedRows = ref<KnowledgeItem[]>([]);
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 filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" });
const form = reactive({ const form = reactive({
name: "", name: "",
@@ -32,6 +39,8 @@ const form = reactive({
status: 0, status: 0,
remark: "", remark: "",
}); });
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
const filteredRows = computed(() => rows.value.filter((item) => { const filteredRows = computed(() => rows.value.filter((item) => {
if (filter.keyword && !`${item.name}${item.sourceTitle || ""}`.includes(filter.keyword)) return false; if (filter.keyword && !`${item.name}${item.sourceTitle || ""}`.includes(filter.keyword)) return false;
@@ -49,27 +58,131 @@ async function load() {
loading.value = true; loading.value = true;
try { try {
[rows.value, migration.value] = await Promise.all([api.knowledge(), api.migrationSummary()]); [rows.value, migration.value] = await Promise.all([api.knowledge(), api.migrationSummary()]);
} catch (error) {
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
} finally { } finally {
loading.value = false; 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() { async function createKnowledge() {
if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) { if (!form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请填写名称、SpaceID 和 NodeID"); ElMessage.warning("请填写名称、SpaceID 和 NodeID");
return; return;
} }
await api.createKnowledge({ ...form }); try {
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" }); await api.createKnowledge({ ...form });
ElMessage.success("知识库已新增,请进行首次同步"); Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
await load(); ElMessage.success("知识库已新增,请进行首次同步");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败"));
}
} }
async function openDetail(item: KnowledgeItem) { async function openDetail(item: KnowledgeItem) {
detailOpen.value = true; detailOpen.value = true;
activeTab.value = "overview"; activeTab.value = "overview";
versionContent.value = null; 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) { async function refreshDetail(id: number) {
@@ -81,26 +194,35 @@ async function refreshDetail(id: number) {
async function sync(item: KnowledgeItem, mode: "review" | "replace") { async function sync(item: KnowledgeItem, mode: "review" | "replace") {
operatingId.value = item.id; operatingId.value = item.id;
try { try {
await api.syncKnowledge(item.id, mode); const result = await api.syncKnowledge(item.id, mode);
ElMessage.success("同步处理完成,已生成待审核版本"); const message = result.status === "unchanged"
? "飞书内容未变化,未生成新版本"
: result.status === "published"
? "同步、质检和自动发布已完成"
: "同步处理完成,已生成待审核版本";
ElMessage.success(message);
await load(); await load();
if (detailOpen.value) await refreshDetail(item.id); if (detailOpen.value) await refreshDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "知识库同步失败"));
} finally { } finally {
operatingId.value = null; operatingId.value = null;
} }
} }
async function runMigration() { async function runMigration() {
await ElMessageBox.confirm( if (!await confirmAction(
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。", "将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
"开始知识迁移", "开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" }, { confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
); )) return;
loading.value = true; loading.value = true;
try { try {
const result = await api.runKnowledgeMigration(); const result = await api.runKnowledgeMigration();
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`); ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
await load(); await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识迁移失败"));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -108,80 +230,104 @@ async function runMigration() {
async function confirmManifest() { async function confirmManifest() {
if (!detail.value?.pendingVersion) return; if (!detail.value?.pendingVersion) return;
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id); try {
ElMessage.success("知识库说明卡已确认"); await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
await refreshDetail(detail.value.knowledge.id); ElMessage.success("知识库说明卡已确认");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "说明卡确认失败"));
}
} }
async function review(action: "approve" | "reject") { async function review(action: "approve" | "reject") {
if (!detail.value?.pendingVersion) return; if (!detail.value?.pendingVersion) return;
let reason = ""; let reason = "";
if (action === "reject") { if (action === "reject") {
const result = await ElMessageBox.prompt( try {
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。", const result = await ElMessageBox.prompt(
"拒绝版本", "请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" }, "拒绝版本",
); { confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
reason = result.value; );
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) { async function publish(keepOpen: boolean) {
if (!detail.value?.pendingVersion) return; if (!detail.value?.pendingVersion) return;
await ElMessageBox.confirm( if (!await confirmAction(
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}`, `将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}`,
"发布知识版本", "发布知识版本",
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" }, { confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
); )) return;
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen); try {
ElMessage.success("正式版本已发布"); await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
await load(); ElMessage.success("正式版本已发布");
await refreshDetail(detail.value.knowledge.id); await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本发布失败"));
}
} }
async function rollback(version: KnowledgeVersion) { async function rollback(version: KnowledgeVersion) {
if (!detail.value) return; if (!detail.value) return;
await ElMessageBox.confirm( if (!await confirmAction(
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`, `将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
"回滚知识版本", "回滚知识版本",
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" }, { confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
); )) return;
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1); try {
ElMessage.success("知识版本已回滚"); await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
await load(); ElMessage.success("知识版本已回滚");
await refreshDetail(detail.value.knowledge.id); await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本回滚失败"));
}
} }
async function toggleOpen(item: KnowledgeItem) { async function toggleOpen(item: KnowledgeItem) {
const next = item.status !== 1; const next = item.status !== 1;
await ElMessageBox.confirm( if (!await confirmAction(
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。", next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
next ? "开放知识库" : "关闭知识库", next ? "开放知识库" : "关闭知识库",
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" }, { confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
); )) return;
await api.setKnowledgeOpen(item.id, next); try {
ElMessage.success(next ? "知识库已开放" : "知识库已关闭"); await api.setKnowledgeOpen(item.id, next);
await load(); ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
}
} }
async function archive(item: KnowledgeItem) { async function archive(item: KnowledgeItem) {
await ElMessageBox.confirm("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", { if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning", confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
}); })) return;
await api.setKnowledgeLifecycle(item.id, "archived"); await api.setKnowledgeLifecycle(item.id, "archived");
ElMessage.success("知识库已归档"); ElMessage.success("知识库已归档");
await load(); await load();
} }
async function remove(item: KnowledgeItem) { async function remove(item: KnowledgeItem) {
await ElMessageBox.confirm( if (!await confirmAction(
`将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`, `将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`,
"物理删除知识库", "物理删除知识库",
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" }, { confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
); )) return;
await api.deleteKnowledge(item.id); await api.deleteKnowledge(item.id);
ElMessage.success("知识库已删除"); ElMessage.success("知识库已删除");
await load(); await load();
@@ -202,6 +348,23 @@ function statusType(status: string) {
function typeLabel(value: string) { function typeLabel(value: string) {
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value; 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<typeof ElMessageBox.confirm>[2],
) {
try {
await ElMessageBox.confirm(message, title, options);
return true;
} catch {
return false;
}
}
</script> </script>
<template> <template>
@@ -242,13 +405,19 @@ function typeLabel(value: string) {
<el-select v-model="filter.pending" placeholder="待发布版本" clearable><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select> <el-select v-model="filter.pending" placeholder="待发布版本" clearable><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
</div> </div>
<el-table v-loading="loading" :data="filteredRows" stripe> <div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span>
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
</div>
<el-table v-loading="loading" :data="filteredRows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" />
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column> <el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
<el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column> <el-table-column label="类型/审核" width="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column> <el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '无' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</small></template></el-table-column>
<el-table-column label="状态" width="160"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag class="status-gap" :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></template></el-table-column> <el-table-column label="状态" width="160"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag class="status-gap" :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></template></el-table-column>
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" /> <el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
<el-table-column label="操作" width="270" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-button link @click="toggleOpen(row)">{{ row.status === 1 ? '关闭' : '开放' }}</el-button><el-dropdown trigger="click"><el-button link>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item @click="sync(row, 'replace')">同步并准备替换</el-dropdown-item><el-dropdown-item @click="archive(row)">归档</el-dropdown-item><el-dropdown-item divided @click="remove(row)">物理删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column> <el-table-column label="操作" width="270" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row, 'review')">同步</el-button><el-button link :disabled="row.lifecycleStatus === 'archived'" @click="toggleOpen(row)">{{ row.status === 1 ? '关闭' : '开放' }}</el-button><el-dropdown trigger="click" @command="handleMore($event, row)"><el-button link>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
</el-table> </el-table>
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close> <el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
@@ -260,9 +429,12 @@ function typeLabel(value: string) {
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" /> <el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="overview"> <el-tab-pane label="基本信息" name="overview">
<dl class="knowledge-meta"><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl> <div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>审核方式</dt><dd>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>正式版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section> <section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
<div v-if="detail.pendingVersion" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div> <div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="内容结构" name="content"> <el-tab-pane label="内容结构" name="content">
<el-empty v-if="!versionContent" description="请从版本记录中选择一个版本查看" /> <el-empty v-if="!versionContent" description="请从版本记录中选择一个版本查看" />
@@ -273,4 +445,47 @@ function typeLabel(value: string) {
</el-tabs> </el-tabs>
</template> </template>
</el-drawer> </el-drawer>
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" destroy-on-close>
<el-form label-position="top">
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
</el-form>
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
</el-dialog>
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" destroy-on-close>
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
<el-form label-position="top" class="batch-edit-form">
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
</el-dialog>
</template> </template>
<style scoped>
.knowledge-batch-bar,
.knowledge-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.knowledge-batch-bar {
min-height: 48px;
padding: 8px 0;
color: #66756f;
}
.knowledge-section-head h4 {
margin: 0;
}
.batch-edit-form {
margin-top: 20px;
}
</style>

View File

@@ -14,6 +14,7 @@ import type {
KnowledgeVersion, KnowledgeVersion,
KnowledgeVersionContent, KnowledgeVersionContent,
MigrationSummary, MigrationSummary,
KnowledgeBatchUpdateResult,
RetrievalLogItem, RetrievalLogItem,
AttentionItem, AttentionItem,
ModelItem, ModelItem,
@@ -42,9 +43,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken(); const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`); if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { ...init, headers }); const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
const body = (await response.json()) as ApiResponse<T>; const body = (await response.json()) as ApiResponse<T> & { detail?: string };
if (!response.ok || body.code !== 0) { 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; return body.data;
} }
@@ -119,6 +120,10 @@ export const api = {
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }), request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) => updateKnowledge: (id: number, payload: Record<string, unknown>) =>
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }), request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string; reviewMode: string }) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) =>
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }), deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
importKnowledgeFromSpace: (body: { node_token: string }) => importKnowledgeFromSpace: (body: { node_token: string }) =>
request<{ imported: number; skipped: number; id: number; name: string }>( request<{ imported: number; skipped: number; id: number; name: string }>(

View File

@@ -178,6 +178,13 @@ export interface MigrationSummary {
items: KnowledgeItem[]; items: KnowledgeItem[];
} }
export interface KnowledgeBatchUpdateResult {
total: number;
success: number;
failed: number;
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
}
export interface RetrievalLogItem { export interface RetrievalLogItem {
id: number; id: number;
sessionId?: number | null; sessionId?: number | null;

View File

@@ -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")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -24,6 +25,7 @@ from app.models.knowledge import (
from app.schemas.admin import KnowledgeSaveRequest from app.schemas.admin import KnowledgeSaveRequest
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.feishu_service import FeishuKnowledgeService from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
router = APIRouter() router = APIRouter()
@@ -40,6 +42,7 @@ def import_knowledge_from_space(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
_require_super_admin(current_admin)
node_token = payload.get("node_token", "").strip() node_token = payload.get("node_token", "").strip()
if not node_token: if not node_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空") 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), name=info.get("title", node_token),
feishu_space_id=space_id, feishu_space_id=space_id,
feishu_node_id=node_token, feishu_node_id=node_token,
status=1, status=0,
knowledge_type="general", knowledge_type="general",
review_mode="manual", review_mode="manual",
remark=f"obj_type={info.get('obj_type', '')}", remark=f"obj_type={info.get('obj_type', '')}",
@@ -71,8 +74,13 @@ def import_knowledge_from_space(
OperationLogService.write( OperationLogService.write(
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id 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) db.refresh(knowledge)
KnowledgeCatalogCacheService.invalidate()
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name}) 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), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> 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( knowledge = Knowledge(
name=payload.name, name=payload.name,
feishu_space_id=payload.feishuSpaceId, feishu_space_id=payload.feishuSpaceId,
feishu_node_id=payload.feishuNodeId, feishu_node_id=payload.feishuNodeId,
status=payload.status, status=0,
remark=payload.remark, remark=payload.remark,
knowledge_type=payload.knowledgeType, knowledge_type=payload.knowledgeType,
review_mode=payload.reviewMode, review_mode=payload.reviewMode,
@@ -94,8 +111,13 @@ def create_knowledge(
db.add(knowledge) db.add(knowledge)
db.flush() db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id) 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) db.refresh(knowledge)
KnowledgeCatalogCacheService.invalidate()
return api_success(_knowledge_dict(knowledge)) return api_success(_knowledge_dict(knowledge))
@@ -106,11 +128,9 @@ def update_knowledge(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
_require_super_admin(current_admin)
knowledge = _get_knowledge(db, knowledge_id) knowledge = _get_knowledge(db, knowledge_id)
knowledge.name = payload.name 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.remark = payload.remark
knowledge.knowledge_type = payload.knowledgeType knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode 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) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
db.commit() db.commit()
db.refresh(knowledge) db.refresh(knowledge)
KnowledgeCatalogCacheService.invalidate()
return api_success(_knowledge_dict(knowledge)) return api_success(_knowledge_dict(knowledge))
@@ -127,7 +148,13 @@ def delete_knowledge(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
_require_super_admin(current_admin)
knowledge = _get_knowledge(db, knowledge_id) 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( permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id) select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
@@ -146,6 +173,7 @@ def delete_knowledge(
db.delete(knowledge) db.delete(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
db.commit() db.commit()
KnowledgeCatalogCacheService.invalidate()
return api_success() return api_success()
@@ -156,6 +184,11 @@ def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
return 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: def _knowledge_dict(knowledge: Knowledge) -> dict:
return { return {
"id": knowledge.id, "id": knowledge.id,

View File

@@ -21,6 +21,8 @@ from app.models.knowledge import (
) )
from app.schemas.knowledge import ( from app.schemas.knowledge import (
KnowledgeLifecycleRequest, KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest,
KnowledgeMetadataUpdateRequest,
KnowledgePublishRequest, KnowledgePublishRequest,
KnowledgeReviewRequest, KnowledgeReviewRequest,
KnowledgeStatusRequest, KnowledgeStatusRequest,
@@ -28,10 +30,74 @@ from app.schemas.knowledge import (
) )
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
from app.services.knowledge_pipeline_service import KnowledgePipelineService from app.services.knowledge_pipeline_service import KnowledgePipelineService
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
router = APIRouter() 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") @router.get("/knowledge/{knowledge_id}/detail")
def detail( def detail(
knowledge_id: int, knowledge_id: int,
@@ -159,6 +225,7 @@ async def synchronize(
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc 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) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
db.commit() db.commit()
KnowledgeCatalogCacheService.invalidate()
return api_success(_job_dict(job)) return api_success(_job_dict(job))
@@ -171,6 +238,8 @@ def confirm_manifest(
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id) 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: try:
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id) KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
except ValueError as exc: except ValueError as exc:
@@ -190,6 +259,8 @@ def review(
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id) 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": if version.quality_status == "blocked" and payload.action == "approve":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason) KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
@@ -208,12 +279,15 @@ def publish(
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id) 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: try:
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen) KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from 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) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
db.commit() db.commit()
KnowledgeCatalogCacheService.invalidate()
return api_success(_knowledge_detail(knowledge)) return api_success(_knowledge_detail(knowledge))
@@ -227,12 +301,17 @@ def rollback(
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id) 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: try:
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen) KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from 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) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
db.commit() db.commit()
KnowledgeCatalogCacheService.invalidate()
return api_success(_knowledge_detail(knowledge)) return api_success(_knowledge_detail(knowledge))
@@ -258,6 +337,7 @@ def open_status(
db.add(knowledge) db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
db.commit() db.commit()
KnowledgeCatalogCacheService.invalidate()
return api_success(_knowledge_detail(knowledge)) return api_success(_knowledge_detail(knowledge))
@@ -270,12 +350,15 @@ def lifecycle(
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id) knowledge = _knowledge(db, knowledge_id)
if knowledge.lifecycle_status == payload.status:
return api_success(_knowledge_detail(knowledge))
knowledge.lifecycle_status = payload.status knowledge.lifecycle_status = payload.status
if payload.status == "archived": if payload.status == "archived":
knowledge.status = 0 knowledge.status = 0
db.add(knowledge) db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id) OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
db.commit() db.commit()
KnowledgeCatalogCacheService.invalidate()
return api_success(_knowledge_detail(knowledge)) return api_success(_knowledge_detail(knowledge))
@@ -347,6 +430,8 @@ def _knowledge_detail(item: Knowledge) -> dict:
return { return {
"id": item.id, "id": item.id,
"name": item.name, "name": item.name,
"status": item.status,
"remark": item.remark,
"sourceTitle": item.source_title, "sourceTitle": item.source_title,
"feishuSpaceId": item.feishu_space_id, "feishuSpaceId": item.feishu_space_id,
"feishuNodeId": item.feishu_node_id, "feishuNodeId": item.feishu_node_id,
@@ -362,6 +447,8 @@ def _knowledge_detail(item: Knowledge) -> dict:
"lastSyncedAt": item.last_synced_at, "lastSyncedAt": item.last_synced_at,
"lastPublishedAt": item.last_published_at, "lastPublishedAt": item.last_published_at,
"migrationStatus": item.migration_status, "migrationStatus": item.migration_status,
"createdAt": item.created_at,
"updatedAt": item.updated_at,
} }

View File

@@ -12,6 +12,7 @@ PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class Knowledge(Base, TimestampMixin): class Knowledge(Base, TimestampMixin):
__tablename__ = "sys_knowledge" __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) id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)

View File

@@ -7,11 +7,13 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
class AiRequestLog(Base): class AiRequestLog(Base):
__tablename__ = "sys_ai_request_log" __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) session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
message_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) user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
@@ -32,7 +34,7 @@ class AiRequestLog(Base):
class OperationLog(Base): class OperationLog(Base):
__tablename__ = "sys_operation_log" __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) admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
module: Mapped[str] = mapped_column(String(50), nullable=False) module: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False) action: Mapped[str] = mapped_column(String(50), nullable=False)

View File

@@ -24,6 +24,18 @@ class KnowledgeLifecycleRequest(BaseModel):
status: str = Field(pattern="^(active|archived)$") 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): class AttentionUpdateRequest(BaseModel):
status: str = Field(pattern="^(pending|processing|resolved|ignored)$") status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
note: str | None = Field(default=None, max_length=2000) note: str | None = Field(default=None, max_length=2000)

View File

@@ -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

View File

@@ -210,7 +210,7 @@ class KnowledgePipelineService:
boundaries=manifest_data["boundaries"], boundaries=manifest_data["boundaries"],
content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)), content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
change_level=change_level, 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) db.add(manifest)
@@ -236,6 +236,26 @@ class KnowledgePipelineService:
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False {"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
) )
job.finished_at = _now() 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.add_all([knowledge, version, job])
db.commit() db.commit()
return job return job
@@ -299,6 +319,10 @@ class KnowledgePipelineService:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id)) manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
if manifest is None or manifest.confirmed != 1: if manifest is None or manifest.confirmed != 1:
raise ValueError("知识库说明卡尚未确认") 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 previous_version_id = knowledge.current_version_id
if previous_version_id: if previous_version_id:
previous = db.get(KnowledgeVersion, previous_version_id) previous = db.get(KnowledgeVersion, previous_version_id)
@@ -334,12 +358,19 @@ class KnowledgePipelineService:
if version.status not in {"published", "superseded"}: if version.status not in {"published", "superseded"}:
raise ValueError("只能回滚到历史正式版本") raise ValueError("只能回滚到历史正式版本")
previous_id = knowledge.current_version_id 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.current_version_id = version.id
knowledge.status = 1 if keep_open else 0 knowledge.status = 1 if keep_open else 0
knowledge.last_published_at = _now() knowledge.last_published_at = _now()
db.add_all( db.add_all(
[ [
knowledge, knowledge,
version,
KnowledgePublishLog( KnowledgePublishLog(
knowledge_id=knowledge.id, knowledge_id=knowledge.id,
from_version_id=previous_id, from_version_id=previous_id,

View File

@@ -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"