feat(knowledge): auto-publish successful syncs

This commit is contained in:
2026-07-16 18:07:17 +08:00
parent 921b56cfb9
commit 9ba8fb6e0c
12 changed files with 246 additions and 296 deletions

View File

@@ -57,9 +57,11 @@ function selectVersion(value: number) {
function versionStatus(version: KnowledgeVersion) { function versionStatus(version: KnowledgeVersion) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
published: "正式版本", published: "正式版本",
pending_review: "待审核", processing: "同步处理中",
approved: "待发布", pending_review: "未生效版本",
rejected: "已拒绝", approved: "未生效版本",
rejected: "未生效版本",
discarded: "已废弃版本",
superseded: "历史版本", superseded: "历史版本",
}; };
return labels[version.status] ?? version.status; return labels[version.status] ?? version.status;
@@ -136,7 +138,6 @@ function versionStatus(version: KnowledgeVersion) {
<el-table-column prop="title" label="标题" min-width="180" /> <el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip /> <el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
<el-table-column prop="riskLevel" label="风险" width="100" /> <el-table-column prop="riskLevel" label="风险" width="100" />
<el-table-column prop="reviewStatus" label="审核" width="110" />
</el-table> </el-table>
</el-collapse-item> </el-collapse-item>
</el-collapse> </el-collapse>

View File

@@ -34,19 +34,18 @@ const batchSaving = ref(false);
const resolvingNode = ref(false); const resolvingNode = ref(false);
const nodeResolved = ref(false); const nodeResolved = ref(false);
const sourceTitle = ref(""); const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "", pending: "" }); const filter = reactive({ keyword: "", type: "", open: "", source: "" });
const pager = reactive({ page: 1, pageSize: 20, total: 0 }); const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const form = reactive({ const form = reactive({
name: "", name: "",
feishuSpaceId: "", feishuSpaceId: "",
feishuNodeId: "", feishuNodeId: "",
knowledgeType: "general", knowledgeType: "general",
reviewMode: "manual",
status: 0, status: 0,
remark: "", remark: "",
}); });
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" }); const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
const batchForm = reactive({ knowledgeType: "", reviewMode: "" }); const batchForm = reactive({ knowledgeType: "" });
onMounted(load); onMounted(load);
@@ -55,7 +54,7 @@ async function load(page = pager.page, pageSize = pager.pageSize) {
try { try {
const result = await api.knowledge({ const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open, keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes", sourceStatus: filter.source,
page, pageSize, page, pageSize,
}); });
rows.value = result.items; rows.value = result.items;
@@ -74,7 +73,6 @@ function openEdit() {
id: detail.value.knowledge.id, id: detail.value.knowledge.id,
name: detail.value.knowledge.name, name: detail.value.knowledge.name,
knowledgeType: detail.value.knowledge.knowledgeType, knowledgeType: detail.value.knowledge.knowledgeType,
reviewMode: detail.value.knowledge.reviewMode,
}); });
editOpen.value = true; editOpen.value = true;
} }
@@ -87,7 +85,7 @@ async function saveMetadata() {
editSaving.value = true; editSaving.value = true;
try { try {
await api.updateKnowledgeMetadata(editForm.id, { await api.updateKnowledgeMetadata(editForm.id, {
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode, name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
}); });
ElMessage.success("知识库基本信息已更新"); ElMessage.success("知识库基本信息已更新");
editOpen.value = false; editOpen.value = false;
@@ -104,13 +102,13 @@ function openBatchEdit() {
ElMessage.warning("请先勾选需要修改的知识库"); ElMessage.warning("请先勾选需要修改的知识库");
return; return;
} }
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" }); Object.assign(batchForm, { knowledgeType: "" });
batchOpen.value = true; batchOpen.value = true;
} }
async function saveBatchMetadata() { async function saveBatchMetadata() {
if (!batchForm.knowledgeType && !batchForm.reviewMode) { if (!batchForm.knowledgeType) {
ElMessage.warning("请至少选择一个修改项"); ElMessage.warning("请选择知识库类型");
return; return;
} }
if (!await confirmAction( if (!await confirmAction(
@@ -123,7 +121,6 @@ async function saveBatchMetadata() {
const result = await api.batchUpdateKnowledgeMetadata({ const result = await api.batchUpdateKnowledgeMetadata({
knowledgeIds: selectedRows.value.map((item) => item.id), knowledgeIds: selectedRows.value.map((item) => item.id),
knowledgeType: batchForm.knowledgeType || undefined, knowledgeType: batchForm.knowledgeType || undefined,
reviewMode: batchForm.reviewMode || undefined,
}); });
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`); ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`);
batchOpen.value = false; batchOpen.value = false;
@@ -168,9 +165,9 @@ async function createKnowledge() {
} }
try { try {
await api.createKnowledge({ ...form }); await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" }); Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = ""; nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步"); ElMessage.success("知识库已新增,首次同步成功后将自动生效");
await load(); await load();
} catch (error) { } catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败")); ElMessage.error(errorMessage(error, "知识库新增失败"));
@@ -216,15 +213,15 @@ async function refreshDetail(id: number) {
return nextDetail; return nextDetail;
} }
async function sync(item: KnowledgeItem, mode: "review" | "replace") { async function sync(item: KnowledgeItem) {
operatingId.value = item.id; operatingId.value = item.id;
try { try {
const result = await api.syncKnowledge(item.id, mode); const result = await api.syncKnowledge(item.id);
const message = result.status === "unchanged" const message = result.status === "unchanged"
? "飞书内容未变化,未生成新版本" ? "飞书内容未变化,未生成新版本"
: result.status === "published" : result.status === "published"
? "同步、质检和自动发布已完成" ? "同步完成,最新版本已自动生效"
: "同步处理完成,已生成待审核版本"; : "同步完成";
ElMessage.success(message); ElMessage.success(message);
await load(); await load();
if (detailOpen.value) await refreshDetail(item.id); if (detailOpen.value) await refreshDetail(item.id);
@@ -237,7 +234,7 @@ async function sync(item: KnowledgeItem, mode: "review" | "replace") {
async function runMigration() { async function runMigration() {
if (!await confirmAction( if (!await confirmAction(
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。", "将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。",
"开始知识迁移", "开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" }, { confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return; )) return;
@@ -253,58 +250,6 @@ async function runMigration() {
} }
} }
async function confirmManifest() {
if (!detail.value?.pendingVersion) return;
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") {
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, "版本审核失败"));
}
}
async function publish(keepOpen: boolean) {
if (!detail.value?.pendingVersion) return;
if (!await confirmAction(
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}`,
"发布知识版本",
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
)) 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) { async function rollback(version: KnowledgeVersion) {
if (!detail.value) return; if (!detail.value) return;
if (!await confirmAction( if (!await confirmAction(
@@ -399,14 +344,13 @@ async function confirmAction(
<template> <template>
<div class="page-head inline"> <div class="page-head inline">
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步审核和发布相互独立</p></div> <div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步成功后最新版本自动生效历史版本可随时回滚</p></div>
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button> <el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
</div> </div>
<section class="migration-overview"> <section class="migration-overview">
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div> <div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div> <div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
<div><span>待审核</span><strong>{{ migration?.counts.pending_review ?? 0 }}</strong></div>
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div> <div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div> <div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
</section> </section>
@@ -425,11 +369,8 @@ async function confirmAction(
<el-select v-model="form.knowledgeType"> <el-select v-model="form.knowledgeType">
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /> <el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
</el-select> </el-select>
<el-select v-model="form.reviewMode">
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
</el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" /> <el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存并准备同步</el-button> <el-button type="primary" @click="createKnowledge">保存知识库</el-button>
</div> </div>
</section> </section>
@@ -438,46 +379,42 @@ async function confirmAction(
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select> <el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select> <el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select> <el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
<el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
</div> </div>
<div class="knowledge-batch-bar"> <div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span> <span>已选择 {{ selectedRows.length }} 个知识库</span>
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button> <el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型</el-button>
</div> </div>
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event"> <el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" /> <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="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</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></template></el-table-column>
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column> <el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></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="220" fixed="right"><template #default="{ row }"><TableRowActions><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-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</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></TableRowActions></template></el-table-column> <el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><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)">同步最新版本</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</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></TableRowActions></template></el-table-column>
</el-table> </el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" /> <AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close> <el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
<template v-if="detail"> <template v-if="detail">
<div class="knowledge-detail-head"> <div class="knowledge-detail-head">
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div> <div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · 同步后自动生效</p></div>
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div> <div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
</div> </div>
<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">
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div> <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> <dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>版本更新</dt><dd>同步成功后自动替换</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></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></section>
<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" lazy> <el-tab-pane label="内容预览" name="content" lazy>
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" /> <KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane> <el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="mode" label="模式" width="110" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane> <el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
</el-tabs> </el-tabs>
</template> </template>
</el-drawer> </el-drawer>
@@ -486,7 +423,6 @@ async function confirmAction(
<el-form label-position="top"> <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-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.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-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
</el-form> </el-form>
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template> <template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
@@ -496,7 +432,6 @@ async function confirmAction(
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" /> <el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
<el-form label-position="top" class="batch-edit-form"> <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.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> </el-form>
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template> <template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
</el-dialog> </el-dialog>

View File

@@ -139,9 +139,9 @@ 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 }) => updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string }) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }), request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string; reviewMode?: string }) => batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string }) =>
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }), 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 }) =>
@@ -153,14 +153,8 @@ export const api = {
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`), knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`), knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`), knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
syncKnowledge: (id: number, mode: "review" | "replace") => syncKnowledge: (id: number) =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }), request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
confirmKnowledgeManifest: (id: number, versionId: number) =>
request<null>(`/admin/knowledge/${id}/manifest/${versionId}/confirm`, { method: "POST", body: "{}" }),
reviewKnowledgeVersion: (id: number, versionId: number, action: "approve" | "reject", reason?: string) =>
request<null>(`/admin/knowledge/${id}/version/${versionId}/review`, { method: "POST", body: JSON.stringify({ action, reason }) }),
publishKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/publish`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) => rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }), request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
setKnowledgeOpen: (id: number, open: boolean) => setKnowledgeOpen: (id: number, open: boolean) =>

View File

@@ -93,13 +93,10 @@ export interface KnowledgeItem {
remark?: string | null; remark?: string | null;
sourceTitle?: string | null; sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general"; knowledgeType: "course" | "qa" | "general";
reviewMode: "manual" | "automatic";
lifecycleStatus: "active" | "archived"; lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error"; sourceStatus: "normal" | "error";
sourceError?: string | null; sourceError?: string | null;
manifestConfirmed: boolean;
currentVersionId?: number | null; currentVersionId?: number | null;
pendingVersionId?: number | null;
lastSyncedAt?: string | null; lastSyncedAt?: string | null;
lastPublishedAt?: string | null; lastPublishedAt?: string | null;
migrationStatus: string; migrationStatus: string;
@@ -190,7 +187,6 @@ export interface KnowledgeManifest {
export interface KnowledgeDetail { export interface KnowledgeDetail {
knowledge: KnowledgeItem; knowledge: KnowledgeItem;
currentVersion?: KnowledgeVersion | null; currentVersion?: KnowledgeVersion | null;
pendingVersion?: KnowledgeVersion | null;
manifest?: KnowledgeManifest | null; manifest?: KnowledgeManifest | null;
} }

View File

@@ -0,0 +1,37 @@
"""make knowledge synchronization automatically publish
Revision ID: 0011_knowledge_auto_publish
Revises: 0010_prompt_history_management
"""
from alembic import op
import sqlalchemy as sa
revision = "0011_knowledge_auto_publish"
down_revision = "0010_prompt_history_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Keep the legacy column for backward compatibility while removing manual
# review from the active workflow and API.
op.execute("UPDATE sys_knowledge SET review_mode = 'automatic'")
op.alter_column(
"sys_knowledge",
"review_mode",
existing_type=sa.String(length=30),
existing_nullable=False,
server_default="automatic",
)
def downgrade() -> None:
op.alter_column(
"sys_knowledge",
"review_mode",
existing_type=sa.String(length=30),
existing_nullable=False,
server_default="manual",
)

View File

@@ -36,7 +36,6 @@ def list_knowledge(
knowledgeType: str = Query(default=""), knowledgeType: str = Query(default=""),
openStatus: int | None = Query(default=None), openStatus: int | None = Query(default=None),
sourceStatus: str = Query(default=""), sourceStatus: str = Query(default=""),
pending: bool | None = Query(default=None),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100), pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -51,8 +50,6 @@ def list_knowledge(
query = query.where(Knowledge.status == openStatus) query = query.where(Knowledge.status == openStatus)
if sourceStatus: if sourceStatus:
query = query.where(Knowledge.source_status == sourceStatus) query = query.where(Knowledge.source_status == sourceStatus)
if pending is not None:
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all() items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize)) return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
@@ -119,7 +116,7 @@ def import_knowledge_from_space(
feishu_node_id=node_token, feishu_node_id=node_token,
status=0, status=0,
knowledge_type="general", knowledge_type="general",
review_mode="manual", review_mode="automatic",
remark=f"obj_type={info.get('obj_type', '')}", remark=f"obj_type={info.get('obj_type', '')}",
) )
db.add(knowledge) db.add(knowledge)
@@ -153,7 +150,7 @@ def create_knowledge(
status=0, status=0,
remark=payload.remark, remark=payload.remark,
knowledge_type=payload.knowledgeType, knowledge_type=payload.knowledgeType,
review_mode=payload.reviewMode, review_mode="automatic",
) )
db.add(knowledge) db.add(knowledge)
db.flush() db.flush()
@@ -179,7 +176,7 @@ def update_knowledge(
knowledge.name = payload.name knowledge.name = payload.name
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 = "automatic"
db.add(knowledge) db.add(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()
@@ -243,13 +240,10 @@ def _knowledge_dict(knowledge: Knowledge) -> dict:
"remark": knowledge.remark, "remark": knowledge.remark,
"sourceTitle": knowledge.source_title, "sourceTitle": knowledge.source_title,
"knowledgeType": knowledge.knowledge_type, "knowledgeType": knowledge.knowledge_type,
"reviewMode": knowledge.review_mode,
"lifecycleStatus": knowledge.lifecycle_status, "lifecycleStatus": knowledge.lifecycle_status,
"sourceStatus": knowledge.source_status, "sourceStatus": knowledge.source_status,
"sourceError": knowledge.source_error, "sourceError": knowledge.source_error,
"manifestConfirmed": knowledge.manifest_confirmed == 1,
"currentVersionId": knowledge.current_version_id, "currentVersionId": knowledge.current_version_id,
"pendingVersionId": knowledge.pending_version_id,
"lastSyncedAt": knowledge.last_synced_at, "lastSyncedAt": knowledge.last_synced_at,
"lastPublishedAt": knowledge.last_published_at, "lastPublishedAt": knowledge.last_published_at,
"migrationStatus": knowledge.migration_status, "migrationStatus": knowledge.migration_status,

View File

@@ -23,10 +23,8 @@ from app.schemas.knowledge import (
KnowledgeLifecycleRequest, KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest, KnowledgeBatchMetadataUpdateRequest,
KnowledgeMetadataUpdateRequest, KnowledgeMetadataUpdateRequest,
KnowledgePublishRequest, KnowledgeRollbackRequest,
KnowledgeReviewRequest,
KnowledgeStatusRequest, KnowledgeStatusRequest,
KnowledgeSyncRequest,
) )
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
@@ -41,8 +39,8 @@ def batch_update_metadata(
current_admin: Admin = Depends(get_current_admin), current_admin: Admin = Depends(get_current_admin),
) -> dict: ) -> dict:
_require_super_admin(current_admin) _require_super_admin(current_admin)
if payload.knowledgeType is None and payload.reviewMode is None: if payload.knowledgeType is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少选择一个待修改字段") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="选择待修改的知识库类型")
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds)) knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
rows = { rows = {
item.id: item item.id: item
@@ -56,8 +54,6 @@ def batch_update_metadata(
continue continue
if payload.knowledgeType is not None: if payload.knowledgeType is not None:
item.knowledge_type = payload.knowledgeType item.knowledge_type = payload.knowledgeType
if payload.reviewMode is not None:
item.review_mode = payload.reviewMode
db.add(item) db.add(item)
OperationLogService.write( OperationLogService.write(
db, db,
@@ -85,7 +81,7 @@ def update_metadata(
knowledge = _knowledge(db, knowledge_id) knowledge = _knowledge(db, knowledge_id)
knowledge.name = payload.name.strip() knowledge.name = payload.name.strip()
knowledge.knowledge_type = payload.knowledgeType knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode knowledge.review_mode = "automatic"
db.add(knowledge) db.add(knowledge)
OperationLogService.write( OperationLogService.write(
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
@@ -104,16 +100,14 @@ def detail(
_require_super_admin(current_admin) _require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id) knowledge = _knowledge(db, knowledge_id)
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
pending = db.get(KnowledgeVersion, knowledge.pending_version_id) if knowledge.pending_version_id else None
manifest = None manifest = None
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id manifest_version_id = knowledge.current_version_id
if manifest_version_id: if manifest_version_id:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id)) manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
return api_success( return api_success(
{ {
"knowledge": _knowledge_detail(knowledge), "knowledge": _knowledge_detail(knowledge),
"currentVersion": _version_dict(current), "currentVersion": _version_dict(current),
"pendingVersion": _version_dict(pending),
"manifest": _manifest_dict(manifest), "manifest": _manifest_dict(manifest),
} }
) )
@@ -206,7 +200,6 @@ def sync_jobs(
@router.post("/knowledge/{knowledge_id}/sync") @router.post("/knowledge/{knowledge_id}/sync")
async def synchronize( async def synchronize(
knowledge_id: int, knowledge_id: int,
payload: KnowledgeSyncRequest,
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:
@@ -216,7 +209,7 @@ async def synchronize(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
try: try:
job = await KnowledgePipelineService.synchronize( job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode=payload.mode db, knowledge, admin_id=current_admin.id
) )
except Exception as exc: except Exception as exc:
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
@@ -225,72 +218,11 @@ async def synchronize(
return api_success(_job_dict(job)) return api_success(_job_dict(job))
@router.post("/knowledge/{knowledge_id}/manifest/{version_id}/confirm")
def confirm_manifest(
knowledge_id: int,
version_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> 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:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="confirm_manifest", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/review")
def review(
knowledge_id: int,
version_id: int,
payload: KnowledgeReviewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> 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)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=f"review_{payload.action}", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/publish")
def publish(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> 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()
return api_success(_knowledge_detail(knowledge))
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback") @router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
def rollback( def rollback(
knowledge_id: int, knowledge_id: int,
version_id: int, version_id: int,
payload: KnowledgePublishRequest, payload: KnowledgeRollbackRequest,
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:
@@ -321,8 +253,6 @@ def open_status(
if payload.open: if payload.open:
if not knowledge.current_version_id: if not knowledge.current_version_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
if knowledge.manifest_confirmed != 1:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="首次知识库说明卡尚未确认")
if knowledge.source_status != "normal": if knowledge.source_status != "normal":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
if knowledge.lifecycle_status != "active": if knowledge.lifecycle_status != "active":
@@ -381,7 +311,7 @@ async def run_migration(
for knowledge in rows: for knowledge in rows:
try: try:
job = await KnowledgePipelineService.synchronize( job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode="review" db, knowledge, admin_id=current_admin.id
) )
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)}) results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
except Exception as exc: except Exception as exc:
@@ -428,14 +358,11 @@ def _knowledge_detail(item: Knowledge) -> dict:
"feishuSpaceId": item.feishu_space_id, "feishuSpaceId": item.feishu_space_id,
"feishuNodeId": item.feishu_node_id, "feishuNodeId": item.feishu_node_id,
"knowledgeType": item.knowledge_type, "knowledgeType": item.knowledge_type,
"reviewMode": item.review_mode,
"open": item.status == 1, "open": item.status == 1,
"lifecycleStatus": item.lifecycle_status, "lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status, "sourceStatus": item.source_status,
"sourceError": item.source_error, "sourceError": item.source_error,
"manifestConfirmed": item.manifest_confirmed == 1,
"currentVersionId": item.current_version_id, "currentVersionId": item.current_version_id,
"pendingVersionId": item.pending_version_id,
"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,

View File

@@ -22,7 +22,7 @@ class Knowledge(Base, TimestampMixin):
remark: Mapped[str | None] = mapped_column(String(255), nullable=True) remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_title: Mapped[str | None] = mapped_column(String(255), nullable=True) source_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False) knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False)
review_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False) review_mode: Mapped[str] = mapped_column(String(30), default="automatic", nullable=False)
lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False) lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False)
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False) source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
source_error: Mapped[str | None] = mapped_column(Text, nullable=True) source_error: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -71,7 +71,6 @@ class KnowledgeSaveRequest(BaseModel):
status: int = Field(default=1, ge=0, le=1) status: int = Field(default=1, ge=0, le=1)
remark: str | None = Field(default=None, max_length=255) remark: str | None = Field(default=None, max_length=255)
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$") knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
class PromptSaveRequest(BaseModel): class PromptSaveRequest(BaseModel):

View File

@@ -3,16 +3,7 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class KnowledgeSyncRequest(BaseModel): class KnowledgeRollbackRequest(BaseModel):
mode: str = Field(default="review", pattern="^(review|replace)$")
class KnowledgeReviewRequest(BaseModel):
action: str = Field(pattern="^(approve|reject)$")
reason: str | None = Field(default=None, max_length=1000)
class KnowledgePublishRequest(BaseModel):
keepOpen: bool = False keepOpen: bool = False
@@ -27,13 +18,11 @@ class KnowledgeLifecycleRequest(BaseModel):
class KnowledgeMetadataUpdateRequest(BaseModel): class KnowledgeMetadataUpdateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
knowledgeType: str = Field(pattern="^(course|qa|general)$") knowledgeType: str = Field(pattern="^(course|qa|general)$")
reviewMode: str = Field(pattern="^(manual|automatic)$")
class KnowledgeBatchMetadataUpdateRequest(BaseModel): class KnowledgeBatchMetadataUpdateRequest(BaseModel):
knowledgeIds: list[int] = Field(min_length=1, max_length=500) knowledgeIds: list[int] = Field(min_length=1, max_length=500)
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$") 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):

View File

@@ -15,7 +15,6 @@ from app.models.knowledge import (
KnowledgeChunk, KnowledgeChunk,
KnowledgeManifest, KnowledgeManifest,
KnowledgePublishLog, KnowledgePublishLog,
KnowledgeReviewRecord,
KnowledgeSection, KnowledgeSection,
KnowledgeSourceSnapshot, KnowledgeSourceSnapshot,
KnowledgeSyncJob, KnowledgeSyncJob,
@@ -25,7 +24,7 @@ from app.services.external_errors import ExternalServiceError
from app.services.feishu_async_service import AsyncFeishuKnowledgeService from app.services.feishu_async_service import AsyncFeishuKnowledgeService
from app.services.feishu_service import _feishu_retrieval_config from app.services.feishu_service import _feishu_retrieval_config
PROCESSING_RULE_VERSION = "knowledge-pipeline-v1" PROCESSING_RULE_VERSION = "knowledge-pipeline-v2-auto-publish"
MAX_SECTION_CHARS = 2800 MAX_SECTION_CHARS = 2800
MAX_CHUNK_CHARS = 650 MAX_CHUNK_CHARS = 650
@@ -46,13 +45,10 @@ class KnowledgePipelineService:
knowledge: Knowledge, knowledge: Knowledge,
*, *,
admin_id: int, admin_id: int,
mode: str,
) -> KnowledgeSyncJob: ) -> KnowledgeSyncJob:
if mode not in {"review", "replace"}:
raise ValueError("同步模式必须为 review 或 replace")
job = KnowledgeSyncJob( job = KnowledgeSyncJob(
knowledge_id=knowledge.id, knowledge_id=knowledge.id,
mode=mode, mode="replace",
status="running", status="running",
stage="fetching", stage="fetching",
progress=5, progress=5,
@@ -88,10 +84,38 @@ class KnowledgePipelineService:
knowledge.source_error = None knowledge.source_error = None
knowledge.source_title = source_title knowledge.source_title = source_title
knowledge.last_synced_at = _now() knowledge.last_synced_at = _now()
latest_version = db.scalar(
select(KnowledgeVersion)
.where(KnowledgeVersion.snapshot_id == latest_snapshot.id)
.order_by(KnowledgeVersion.id.desc())
.limit(1)
)
if latest_version is not None and latest_version.id != knowledge.current_version_id:
if latest_version.quality_status == "blocked":
raise ValueError("最新同步版本质检未通过,已保留当前正式版本")
cls._activate_version(
db,
knowledge,
latest_version,
admin_id,
keep_open=knowledge.status == 1,
action="sync_publish",
)
job.status = "published"
job.result_summary = json.dumps(
{
"changed": False,
"activatedExistingVersion": True,
"versionId": latest_version.id,
"versionNo": latest_version.version_no,
},
ensure_ascii=False,
)
else:
job.status = "unchanged" job.status = "unchanged"
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
job.stage = "completed" job.stage = "completed"
job.progress = 100 job.progress = 100
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
job.finished_at = _now() job.finished_at = _now()
db.add_all([knowledge, job]) db.add_all([knowledge, job])
db.commit() db.commit()
@@ -188,7 +212,7 @@ class KnowledgePipelineService:
content_hash=section.content_hash, content_hash=section.content_hash,
generation_model="deterministic-v1", generation_model="deterministic-v1",
generation_rule_version=PROCESSING_RULE_VERSION, generation_rule_version=PROCESSING_RULE_VERSION,
review_status="pending", review_status="approved",
) )
) )
@@ -210,7 +234,9 @@ 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 previous_manifest.confirmed == 1 and change_level == "minor" else 0, confirmed=1,
confirmed_by=admin_id,
confirmed_at=_now(),
) )
db.add(manifest) db.add(manifest)
@@ -222,40 +248,32 @@ class KnowledgePipelineService:
{"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level}, {"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
ensure_ascii=False, ensure_ascii=False,
) )
version.status = "pending_review" if quality["blocking"]:
issue_text = "".join(
item["message"] for item in quality["issues"] if item["level"] == "blocking"
)
raise ValueError(f"同步内容质检未通过:{issue_text or '存在阻断项'}")
knowledge.source_title = source_title knowledge.source_title = source_title
knowledge.source_status = "normal" knowledge.source_status = "normal"
knowledge.source_error = None knowledge.source_error = None
knowledge.pending_version_id = version.id
knowledge.last_synced_at = _now() knowledge.last_synced_at = _now()
knowledge.migration_status = "pending_review" cls._activate_version(
job.status = "pending_review"
job.stage = "completed"
job.progress = 100
job.result_summary = json.dumps(
{"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, db,
knowledge, knowledge,
version, version,
admin_id, admin_id,
"approve", keep_open=knowledge.status == 1,
"自动审核方式:质检通过且说明卡可继承,自动发布。", action="sync_publish",
) )
cls.publish_version(db, knowledge, version, admin_id, keep_open=knowledge.status == 1)
job.status = "published" job.status = "published"
job.stage = "completed"
job.progress = 100
job.result_summary = json.dumps( job.result_summary = json.dumps(
{"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality}, {"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
ensure_ascii=False, ensure_ascii=False,
) )
job.finished_at = _now()
db.add_all([knowledge, version, job]) db.add_all([knowledge, version, job])
db.commit() db.commit()
return job return job
@@ -278,57 +296,34 @@ class KnowledgePipelineService:
raise raise
@staticmethod @staticmethod
def confirm_manifest(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int) -> None: def _activate_version(
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id)) db: Session,
if manifest is None: knowledge: Knowledge,
raise ValueError("该版本没有知识库说明卡") version: KnowledgeVersion,
manifest.confirmed = 1 admin_id: int,
manifest.confirmed_by = admin_id *,
manifest.confirmed_at = _now() keep_open: bool,
if knowledge.pending_version_id == version.id: action: str,
knowledge.manifest_confirmed = 1 ) -> None:
db.add_all([manifest, knowledge]) """Atomically activate a validated version and preserve rollback history."""
if version.quality_status == "blocked":
@staticmethod raise ValueError("质检未通过的版本不能生效")
def review_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, action: str, reason: str | None) -> None:
if action not in {"approve", "reject"}:
raise ValueError("审核动作不正确")
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == version.id)).all()
review_status = "approved" if action == "approve" else "rejected"
for card in cards:
card.review_status = review_status
db.add(card)
version.status = "approved" if action == "approve" else "rejected"
db.add_all(
[
version,
KnowledgeReviewRecord(
knowledge_id=knowledge.id,
version_id=version.id,
action=action,
reason=reason,
reviewed_by=admin_id,
),
]
)
@staticmethod
def publish_version(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
if version.status != "approved":
raise ValueError("只有审核通过的版本可以发布")
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": if keep_open and knowledge.lifecycle_status != "active":
raise ValueError("归档知识库不能在发布后开放") raise ValueError("归档知识库不能在版本替换后开放")
if keep_open and knowledge.source_status != "normal": if keep_open and knowledge.source_status != "normal":
raise ValueError("飞书源文章异常,不能在发布后开放") 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)
if previous: if previous and previous.id != version.id:
previous.status = "superseded" previous.status = "superseded"
db.add(previous) db.add(previous)
previous_pending_id = knowledge.pending_version_id
if previous_pending_id and previous_pending_id not in {previous_version_id, version.id}:
previous_pending = db.get(KnowledgeVersion, previous_pending_id)
if previous_pending and previous_pending.status not in {"published", "superseded"}:
previous_pending.status = "discarded"
db.add(previous_pending)
version.status = "published" version.status = "published"
version.published_by = admin_id version.published_by = admin_id
version.published_at = _now() version.published_at = _now()
@@ -346,7 +341,7 @@ class KnowledgePipelineService:
knowledge_id=knowledge.id, knowledge_id=knowledge.id,
from_version_id=previous_version_id, from_version_id=previous_version_id,
to_version_id=version.id, to_version_id=version.id,
action="publish", action=action,
keep_open=knowledge.status, keep_open=knowledge.status,
operated_by=admin_id, operated_by=admin_id,
), ),
@@ -510,7 +505,7 @@ def quality_check(content: str, sections: list[ParsedSection], chunk_count: int)
issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"}) issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"})
high_risk = detect_risk_level(content) == "high" high_risk = detect_risk_level(content) == "high"
if high_risk: if high_risk:
issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,需要重点审核"}) issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题,请重点关注内容准确性"})
return { return {
"blocking": any(item["level"] == "blocking" for item in issues), "blocking": any(item["level"] == "blocking" for item in issues),
"issues": issues, "issues": issues,

View File

@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
@@ -9,7 +12,14 @@ from sqlalchemy.pool import StaticPool
from app.api.admin_knowledge_lifecycle import batch_update_metadata, update_metadata from app.api.admin_knowledge_lifecycle import batch_update_metadata, update_metadata
from app.models import Base from app.models import Base
from app.models.admin import Admin, Role from app.models.admin import Admin, Role
from app.models.knowledge import Knowledge, KnowledgeManifest, KnowledgeSourceSnapshot, KnowledgeVersion from app.models.knowledge import (
Knowledge,
KnowledgeCard,
KnowledgeManifest,
KnowledgePublishLog,
KnowledgeSourceSnapshot,
KnowledgeVersion,
)
from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest from app.schemas.knowledge import KnowledgeBatchMetadataUpdateRequest, KnowledgeMetadataUpdateRequest
from app.services.knowledge_agent_service import KnowledgeAgentService from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.knowledge_pipeline_service import KnowledgePipelineService from app.services.knowledge_pipeline_service import KnowledgePipelineService
@@ -82,7 +92,7 @@ def test_metadata_update_preserves_source_version_and_open_state():
original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status) original = (item.feishu_space_id, item.feishu_node_id, item.current_version_id, item.status)
update_metadata( update_metadata(
item.id, item.id,
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"), KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course"),
db, db,
_admin(), _admin(),
) )
@@ -98,7 +108,7 @@ def test_metadata_update_requires_super_admin():
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
update_metadata( update_metadata(
item.id, item.id,
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"), KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa"),
db, db,
_admin("operator"), _admin("operator"),
) )
@@ -138,3 +148,76 @@ def test_rollback_switches_published_version_statuses():
assert item.current_version_id == target.id assert item.current_version_id == target.id
assert target.status == "published" assert target.status == "published"
assert old_version.status == "superseded" assert old_version.status == "superseded"
def test_sync_automatically_replaces_current_version_and_keeps_rollback_history():
source_content = (
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
)
with _database() as db:
item = _knowledge(db)
previous_version_id = item.current_version_id
loader = AsyncMock(return_value=[("新源标题", source_content, None)])
with patch(
"app.services.knowledge_pipeline_service.AsyncFeishuKnowledgeService._load_documents",
loader,
):
job = asyncio.run(
KnowledgePipelineService.synchronize(
db,
item,
admin_id=1,
)
)
db.refresh(item)
current = db.get(KnowledgeVersion, item.current_version_id)
previous = db.get(KnowledgeVersion, previous_version_id)
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id))
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
publish_log = db.scalar(
select(KnowledgePublishLog).where(KnowledgePublishLog.to_version_id == current.id)
)
assert job.status == "published"
assert job.mode == "replace"
assert current.status == "published"
assert current.quality_status == "passed"
assert previous.status == "superseded"
assert item.pending_version_id is None
assert item.status == 1
assert item.migration_status == "completed"
assert manifest.confirmed == 1
assert cards and all(card.review_status == "approved" for card in cards)
assert publish_log.action == "sync_publish"
def test_sync_validation_failure_keeps_current_version_active():
with _database() as db:
item = _knowledge(db)
previous_version_id = item.current_version_id
loader = AsyncMock(return_value=[("无效源内容", "内容过短", None)])
with patch(
"app.services.knowledge_pipeline_service.AsyncFeishuKnowledgeService._load_documents",
loader,
):
with pytest.raises(ValueError, match="质检未通过"):
asyncio.run(
KnowledgePipelineService.synchronize(
db,
item,
admin_id=1,
)
)
db.refresh(item)
current = db.get(KnowledgeVersion, previous_version_id)
versions = db.scalars(
select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == item.id)
).all()
assert item.current_version_id == previous_version_id
assert current.status == "published"
assert len(versions) == 1