refactor(knowledge): keep latest version only

This commit is contained in:
2026-07-16 18:24:49 +08:00
parent 9ba8fb6e0c
commit e3634eb26c
8 changed files with 109 additions and 197 deletions

View File

@@ -2,15 +2,12 @@
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { api } from "../services/api"; import { api } from "../services/api";
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api"; import type { KnowledgeVersionContent } from "../types/api";
const props = defineProps<{ const props = defineProps<{
versions: KnowledgeVersion[];
versionId: number | null; versionId: number | null;
}>(); }>();
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const content = ref<KnowledgeVersionContent | null>(null); const content = ref<KnowledgeVersionContent | null>(null);
@@ -50,22 +47,6 @@ async function loadVersion(versionId: number | null) {
} }
} }
function selectVersion(value: number) {
emit("update:versionId", value);
}
function versionStatus(version: KnowledgeVersion) {
const labels: Record<string, string> = {
published: "正式版本",
processing: "同步处理中",
pending_review: "未生效版本",
approved: "未生效版本",
rejected: "未生效版本",
discarded: "已废弃版本",
superseded: "历史版本",
};
return labels[version.status] ?? version.status;
}
</script> </script>
<template> <template>
@@ -73,22 +54,8 @@ function versionStatus(version: KnowledgeVersion) {
<header class="content-preview-toolbar"> <header class="content-preview-toolbar">
<div> <div>
<h4>知识库内容预览</h4> <h4>知识库内容预览</h4>
<p>按同步后的章节结构查看实际进入检索链路的内容</p> <p>查看当前最新版实际进入检索链路的章节内容</p>
</div> </div>
<el-select
:model-value="versionId"
class="content-version-select"
placeholder="选择预览版本"
:disabled="versions.length === 0"
@update:model-value="selectVersion"
>
<el-option
v-for="version in versions"
:key="version.id"
:label="`V${version.versionNo} · ${versionStatus(version)}`"
:value="version.id"
/>
</el-select>
</header> </header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon> <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
@@ -150,7 +117,6 @@ function versionStatus(version: KnowledgeVersion) {
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; } .content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; } .content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; } .content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
.content-version-select { width: 220px; flex: 0 0 220px; }
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } .content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; } .content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; } .content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
@@ -170,7 +136,6 @@ function versionStatus(version: KnowledgeVersion) {
.content-card-panel { margin-top: 16px; } .content-card-panel { margin-top: 16px; }
@media (max-width: 900px) { @media (max-width: 900px) {
.content-preview-toolbar { align-items: stretch; flex-direction: column; } .content-preview-toolbar { align-items: stretch; flex-direction: column; }
.content-version-select { width: 100%; flex-basis: auto; }
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; } .content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; } .content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
.content-outline > strong { display: none; } .content-outline > strong { display: none; }

View File

@@ -10,7 +10,6 @@ import type {
KnowledgeDetail, KnowledgeDetail,
KnowledgeItem, KnowledgeItem,
KnowledgeSyncJob, KnowledgeSyncJob,
KnowledgeVersion,
MigrationSummary, MigrationSummary,
} from "../types/api"; } from "../types/api";
@@ -22,7 +21,6 @@ const loading = ref(false);
const operatingId = ref<number | null>(null); const operatingId = ref<number | null>(null);
const detailOpen = ref(false); const detailOpen = ref(false);
const detail = ref<KnowledgeDetail | null>(null); const detail = ref<KnowledgeDetail | null>(null);
const versions = ref<KnowledgeVersion[]>([]);
const jobs = ref<KnowledgeSyncJob[]>([]); const jobs = ref<KnowledgeSyncJob[]>([]);
const previewVersionId = ref<number | null>(null); const previewVersionId = ref<number | null>(null);
const activeTab = ref("overview"); const activeTab = ref("overview");
@@ -136,11 +134,6 @@ async function handleMore(command: string, item: KnowledgeItem) {
try { try {
if (command === "detail") return await openDetail(item); if (command === "detail") return await openDetail(item);
if (command === "preview") return emit("preview", item.id); 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 === "archive") return await archive(item);
if (command === "restore") return await restore(item); if (command === "restore") return await restore(item);
if (command === "delete") return await remove(item); if (command === "delete") return await remove(item);
@@ -196,7 +189,7 @@ async function openDetail(item: KnowledgeItem) {
previewVersionId.value = null; previewVersionId.value = null;
try { try {
const refreshedDetail = await refreshDetail(item.id); const refreshedDetail = await refreshDetail(item.id);
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null; previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
} catch (error) { } catch (error) {
detailOpen.value = false; detailOpen.value = false;
ElMessage.error(errorMessage(error, "知识库详情加载失败")); ElMessage.error(errorMessage(error, "知识库详情加载失败"));
@@ -204,11 +197,10 @@ async function openDetail(item: KnowledgeItem) {
} }
async function refreshDetail(id: number) { async function refreshDetail(id: number) {
const [nextDetail, nextVersions, nextJobs] = await Promise.all([ const [nextDetail, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id), api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
]); ]);
detail.value = nextDetail; detail.value = nextDetail;
versions.value = nextVersions;
jobs.value = nextJobs; jobs.value = nextJobs;
return nextDetail; return nextDetail;
} }
@@ -234,7 +226,7 @@ async function sync(item: KnowledgeItem) {
async function runMigration() { async function runMigration() {
if (!await confirmAction( if (!await confirmAction(
"将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。", "将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
"开始知识迁移", "开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" }, { confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return; )) return;
@@ -250,23 +242,6 @@ async function runMigration() {
} }
} }
async function rollback(version: KnowledgeVersion) {
if (!detail.value) return;
if (!await confirmAction(
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
"回滚知识版本",
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
ElMessage.success("知识版本已回滚");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本回滚失败"));
}
}
async function toggleOpen(item: KnowledgeItem): Promise<boolean> { async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
const next = item.status !== 1; const next = item.status !== 1;
if (!await confirmAction( if (!await confirmAction(
@@ -289,7 +264,7 @@ async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
} }
async function archive(item: KnowledgeItem) { async function archive(item: KnowledgeItem) {
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录保留。", "归档知识库", { if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning", confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
})) return; })) return;
await api.setKnowledgeLifecycle(item.id, "archived"); await api.setKnowledgeLifecycle(item.id, "archived");
@@ -308,11 +283,6 @@ async function remove(item: KnowledgeItem) {
await load(); await load();
} }
async function showVersion(version: KnowledgeVersion) {
previewVersionId.value = version.id;
activeTab.value = "content";
}
function statusType(status: string) { function statusType(status: string) {
if (["published", "passed", "normal", "completed"].includes(status)) return "success"; if (["published", "passed", "normal", "completed"].includes(status)) return "success";
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger"; if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
@@ -344,7 +314,7 @@ 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>
@@ -393,7 +363,7 @@ async function confirmAction(
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></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)">同步最新版</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 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" />
@@ -411,9 +381,8 @@ async function confirmAction(
<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> <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>
</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 :version-id="previewVersionId" />
</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="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-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>

View File

@@ -13,7 +13,6 @@ import type {
KnowledgeItem, KnowledgeItem,
KnowledgeDetail, KnowledgeDetail,
KnowledgeSyncJob, KnowledgeSyncJob,
KnowledgeVersion,
KnowledgeVersionContent, KnowledgeVersionContent,
MigrationSummary, MigrationSummary,
KnowledgeBatchUpdateResult, KnowledgeBatchUpdateResult,
@@ -150,13 +149,10 @@ export const api = {
{ method: "POST", body: JSON.stringify(body) }, { method: "POST", body: JSON.stringify(body) },
), ),
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`), knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
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) => syncKnowledge: (id: number) =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }), request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
setKnowledgeOpen: (id: number, open: boolean) => setKnowledgeOpen: (id: number, open: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }), request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
setKnowledgeLifecycle: (id: number, status: "active" | "archived") => setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>

View File

@@ -0,0 +1,55 @@
"""keep only the latest knowledge version
Revision ID: 0012_knowledge_latest_only
Revises: 0011_knowledge_auto_publish
"""
from alembic import op
revision = "0012_knowledge_latest_only"
down_revision = "0011_knowledge_auto_publish"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("UPDATE sys_knowledge SET pending_version_id = NULL")
for table in (
"sys_knowledge_review_record",
"sys_knowledge_card",
"sys_knowledge_chunk",
"sys_knowledge_section",
"sys_knowledge_manifest",
):
op.execute(
f"""
DELETE detail
FROM {table} AS detail
JOIN sys_knowledge AS knowledge ON knowledge.id = detail.knowledge_id
WHERE knowledge.current_version_id IS NULL
OR detail.version_id <> knowledge.current_version_id
"""
)
op.execute(
"""
DELETE version
FROM sys_knowledge_version AS version
JOIN sys_knowledge AS knowledge ON knowledge.id = version.knowledge_id
WHERE knowledge.current_version_id IS NULL
OR version.id <> knowledge.current_version_id
"""
)
op.execute(
"""
DELETE snapshot
FROM sys_knowledge_source_snapshot AS snapshot
LEFT JOIN sys_knowledge_version AS version ON version.snapshot_id = snapshot.id
WHERE version.id IS NULL
"""
)
def downgrade() -> None:
# Removed historical content cannot be reconstructed from the latest version.
pass

View File

@@ -23,7 +23,6 @@ from app.schemas.knowledge import (
KnowledgeLifecycleRequest, KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest, KnowledgeBatchMetadataUpdateRequest,
KnowledgeMetadataUpdateRequest, KnowledgeMetadataUpdateRequest,
KnowledgeRollbackRequest,
KnowledgeStatusRequest, KnowledgeStatusRequest,
) )
from app.services.admin_service import OperationLogService from app.services.admin_service import OperationLogService
@@ -113,22 +112,6 @@ def detail(
) )
@router.get("/knowledge/{knowledge_id}/versions")
def versions(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
_knowledge(db, knowledge_id)
rows = db.scalars(
select(KnowledgeVersion)
.where(KnowledgeVersion.knowledge_id == knowledge_id)
.order_by(KnowledgeVersion.version_no.desc())
).all()
return api_success([_version_dict(item) for item in rows])
@router.get("/knowledge/version/{version_id}/content") @router.get("/knowledge/version/{version_id}/content")
def version_content( def version_content(
version_id: int, version_id: int,
@@ -218,29 +201,6 @@ async def synchronize(
return api_success(_job_dict(job)) return api_success(_job_dict(job))
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
def rollback(
knowledge_id: int,
version_id: int,
payload: KnowledgeRollbackRequest,
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.current_version_id == version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
if payload.keepOpen and knowledge.lifecycle_status != "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
try:
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.put("/knowledge/{knowledge_id}/open-status") @router.put("/knowledge/{knowledge_id}/open-status")
def open_status( def open_status(
knowledge_id: int, knowledge_id: int,
@@ -340,14 +300,6 @@ def _version(db: Session, version_id: int) -> KnowledgeVersion:
return item return item
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
knowledge = _knowledge(db, knowledge_id)
version = _version(db, version_id)
if version.knowledge_id != knowledge.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
return knowledge, version
def _knowledge_detail(item: Knowledge) -> dict: def _knowledge_detail(item: Knowledge) -> dict:
return { return {
"id": item.id, "id": item.id,

View File

@@ -3,10 +3,6 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class KnowledgeRollbackRequest(BaseModel):
keepOpen: bool = False
class KnowledgeStatusRequest(BaseModel): class KnowledgeStatusRequest(BaseModel):
open: bool open: bool

View File

@@ -6,7 +6,7 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.knowledge import ( from app.models.knowledge import (
@@ -15,6 +15,7 @@ from app.models.knowledge import (
KnowledgeChunk, KnowledgeChunk,
KnowledgeManifest, KnowledgeManifest,
KnowledgePublishLog, KnowledgePublishLog,
KnowledgeReviewRecord,
KnowledgeSection, KnowledgeSection,
KnowledgeSourceSnapshot, KnowledgeSourceSnapshot,
KnowledgeSyncJob, KnowledgeSyncJob,
@@ -24,7 +25,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-v2-auto-publish" PROCESSING_RULE_VERSION = "knowledge-pipeline-v3-latest-only"
MAX_SECTION_CHARS = 2800 MAX_SECTION_CHARS = 2800
MAX_CHUNK_CHARS = 650 MAX_CHUNK_CHARS = 650
@@ -114,6 +115,8 @@ class KnowledgePipelineService:
else: else:
job.status = "unchanged" job.status = "unchanged"
job.result_summary = json.dumps({"changed": False}, ensure_ascii=False) job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
if latest_version is not None:
cls._prune_old_versions(db, knowledge, latest_version)
job.stage = "completed" job.stage = "completed"
job.progress = 100 job.progress = 100
job.finished_at = _now() job.finished_at = _now()
@@ -305,7 +308,7 @@ class KnowledgePipelineService:
keep_open: bool, keep_open: bool,
action: str, action: str,
) -> None: ) -> None:
"""Atomically activate a validated version and preserve rollback history.""" """Atomically replace the active version with validated latest content."""
if version.quality_status == "blocked": if version.quality_status == "blocked":
raise ValueError("质检未通过的版本不能生效") raise ValueError("质检未通过的版本不能生效")
if keep_open and knowledge.lifecycle_status != "active": if keep_open and knowledge.lifecycle_status != "active":
@@ -313,17 +316,6 @@ class KnowledgePipelineService:
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:
previous = db.get(KnowledgeVersion, previous_version_id)
if previous and previous.id != version.id:
previous.status = "superseded"
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()
@@ -347,34 +339,36 @@ class KnowledgePipelineService:
), ),
] ]
) )
db.flush()
KnowledgePipelineService._prune_old_versions(db, knowledge, version)
@staticmethod @staticmethod
def rollback(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None: def _prune_old_versions(db: Session, knowledge: Knowledge, keep: KnowledgeVersion) -> None:
if version.status not in {"published", "superseded"}: """Keep only the active version and its derived retrieval data."""
raise ValueError("只能回滚到历史正式版本") old_version_ids = list(db.scalars(
previous_id = knowledge.current_version_id select(KnowledgeVersion.id).where(
if previous_id: KnowledgeVersion.knowledge_id == knowledge.id,
previous = db.get(KnowledgeVersion, previous_id) KnowledgeVersion.id != keep.id,
if previous: )
previous.status = "superseded" ).all())
db.add(previous) if old_version_ids:
version.status = "published" for model in (
knowledge.current_version_id = version.id KnowledgeReviewRecord,
knowledge.status = 1 if keep_open else 0 KnowledgeCard,
knowledge.last_published_at = _now() KnowledgeChunk,
db.add_all( KnowledgeSection,
[ KnowledgeManifest,
knowledge, ):
version, db.execute(delete(model).where(model.version_id.in_(old_version_ids)))
KnowledgePublishLog( db.execute(delete(KnowledgeVersion).where(KnowledgeVersion.id.in_(old_version_ids)))
knowledge_id=knowledge.id,
from_version_id=previous_id, # Snapshots are source copies for a version. Once that version is gone,
to_version_id=version.id, # retaining the old snapshot would still be retaining knowledge history.
action="rollback", db.execute(
keep_open=knowledge.status, delete(KnowledgeSourceSnapshot).where(
operated_by=admin_id, KnowledgeSourceSnapshot.knowledge_id == knowledge.id,
), KnowledgeSourceSnapshot.id != keep.snapshot_id,
] )
) )
@staticmethod @staticmethod

View File

@@ -128,29 +128,7 @@ def test_batch_metadata_update_reports_missing_items():
assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa" assert db.scalar(select(Knowledge).where(Knowledge.id == item.id)).knowledge_type == "qa"
def test_rollback_switches_published_version_statuses(): def test_sync_automatically_replaces_current_version_and_removes_history():
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"
def test_sync_automatically_replaces_current_version_and_keeps_rollback_history():
source_content = ( source_content = (
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n" "# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。" "# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
@@ -174,7 +152,12 @@ def test_sync_automatically_replaces_current_version_and_keeps_rollback_history(
db.refresh(item) db.refresh(item)
current = db.get(KnowledgeVersion, item.current_version_id) current = db.get(KnowledgeVersion, item.current_version_id)
previous = db.get(KnowledgeVersion, previous_version_id) versions = db.scalars(
select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == item.id)
).all()
snapshots = db.scalars(
select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id)
).all()
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id)) manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == current.id))
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all() cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
publish_log = db.scalar( publish_log = db.scalar(
@@ -185,7 +168,9 @@ def test_sync_automatically_replaces_current_version_and_keeps_rollback_history(
assert job.mode == "replace" assert job.mode == "replace"
assert current.status == "published" assert current.status == "published"
assert current.quality_status == "passed" assert current.quality_status == "passed"
assert previous.status == "superseded" assert db.get(KnowledgeVersion, previous_version_id) is None
assert [version.id for version in versions] == [current.id]
assert [snapshot.id for snapshot in snapshots] == [current.snapshot_id]
assert item.pending_version_id is None assert item.pending_version_id is None
assert item.status == 1 assert item.status == 1
assert item.migration_status == "completed" assert item.migration_status == "completed"