refactor(knowledge): keep latest version only
This commit is contained in:
@@ -2,15 +2,12 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
|
||||
import type { KnowledgeVersionContent } from "../types/api";
|
||||
|
||||
const props = defineProps<{
|
||||
versions: KnowledgeVersion[];
|
||||
versionId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -73,22 +54,8 @@ function versionStatus(version: KnowledgeVersion) {
|
||||
<header class="content-preview-toolbar">
|
||||
<div>
|
||||
<h4>知识库内容预览</h4>
|
||||
<p>按同步后的章节结构查看实际进入检索链路的内容。</p>
|
||||
<p>查看当前最新版实际进入检索链路的章节内容。</p>
|
||||
</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>
|
||||
|
||||
<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 h4 { margin: 0; font-size: 17px; color: #203b32; }
|
||||
.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 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; }
|
||||
@@ -170,7 +136,6 @@ function versionStatus(version: KnowledgeVersion) {
|
||||
.content-card-panel { margin-top: 16px; }
|
||||
@media (max-width: 900px) {
|
||||
.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-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; }
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
KnowledgeDetail,
|
||||
KnowledgeItem,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
MigrationSummary,
|
||||
} from "../types/api";
|
||||
|
||||
@@ -22,7 +21,6 @@ const loading = ref(false);
|
||||
const operatingId = ref<number | null>(null);
|
||||
const detailOpen = ref(false);
|
||||
const detail = ref<KnowledgeDetail | null>(null);
|
||||
const versions = ref<KnowledgeVersion[]>([]);
|
||||
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||
const previewVersionId = ref<number | null>(null);
|
||||
const activeTab = ref("overview");
|
||||
@@ -136,11 +134,6 @@ async function handleMore(command: string, item: KnowledgeItem) {
|
||||
try {
|
||||
if (command === "detail") return await openDetail(item);
|
||||
if (command === "preview") return emit("preview", item.id);
|
||||
if (command === "versions") {
|
||||
await openDetail(item);
|
||||
if (detailOpen.value) activeTab.value = "versions";
|
||||
return;
|
||||
}
|
||||
if (command === "archive") return await archive(item);
|
||||
if (command === "restore") return await restore(item);
|
||||
if (command === "delete") return await remove(item);
|
||||
@@ -196,7 +189,7 @@ async function openDetail(item: KnowledgeItem) {
|
||||
previewVersionId.value = null;
|
||||
try {
|
||||
const refreshedDetail = await refreshDetail(item.id);
|
||||
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null;
|
||||
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
|
||||
} catch (error) {
|
||||
detailOpen.value = false;
|
||||
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
||||
@@ -204,11 +197,10 @@ async function openDetail(item: KnowledgeItem) {
|
||||
}
|
||||
|
||||
async function refreshDetail(id: number) {
|
||||
const [nextDetail, nextVersions, nextJobs] = await Promise.all([
|
||||
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
|
||||
const [nextDetail, nextJobs] = await Promise.all([
|
||||
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
|
||||
]);
|
||||
detail.value = nextDetail;
|
||||
versions.value = nextVersions;
|
||||
jobs.value = nextJobs;
|
||||
return nextDetail;
|
||||
}
|
||||
@@ -234,7 +226,7 @@ async function sync(item: KnowledgeItem) {
|
||||
|
||||
async function runMigration() {
|
||||
if (!await confirmAction(
|
||||
"将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。",
|
||||
"将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
|
||||
"开始知识迁移",
|
||||
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||
)) 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> {
|
||||
const next = item.status !== 1;
|
||||
if (!await confirmAction(
|
||||
@@ -289,7 +264,7 @@ async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function archive(item: KnowledgeItem) {
|
||||
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
|
||||
if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
|
||||
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
||||
})) return;
|
||||
await api.setKnowledgeLifecycle(item.id, "archived");
|
||||
@@ -308,11 +283,6 @@ async function remove(item: KnowledgeItem) {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function showVersion(version: KnowledgeVersion) {
|
||||
previewVersionId.value = version.id;
|
||||
activeTab.value = "content";
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
|
||||
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
|
||||
@@ -344,7 +314,7 @@ async function confirmAction(
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</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="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 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>
|
||||
<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>
|
||||
</el-tab-pane>
|
||||
<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 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-tabs>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
KnowledgeItem,
|
||||
KnowledgeDetail,
|
||||
KnowledgeSyncJob,
|
||||
KnowledgeVersion,
|
||||
KnowledgeVersionContent,
|
||||
MigrationSummary,
|
||||
KnowledgeBatchUpdateResult,
|
||||
@@ -150,13 +149,10 @@ export const api = {
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
),
|
||||
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`),
|
||||
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
||||
syncKnowledge: (id: number) =>
|
||||
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) =>
|
||||
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
|
||||
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
|
||||
|
||||
@@ -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
|
||||
@@ -23,7 +23,6 @@ from app.schemas.knowledge import (
|
||||
KnowledgeLifecycleRequest,
|
||||
KnowledgeBatchMetadataUpdateRequest,
|
||||
KnowledgeMetadataUpdateRequest,
|
||||
KnowledgeRollbackRequest,
|
||||
KnowledgeStatusRequest,
|
||||
)
|
||||
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")
|
||||
def version_content(
|
||||
version_id: int,
|
||||
@@ -218,29 +201,6 @@ async def synchronize(
|
||||
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")
|
||||
def open_status(
|
||||
knowledge_id: int,
|
||||
@@ -340,14 +300,6 @@ def _version(db: Session, version_id: int) -> KnowledgeVersion:
|
||||
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:
|
||||
return {
|
||||
"id": item.id,
|
||||
|
||||
@@ -3,10 +3,6 @@ from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KnowledgeRollbackRequest(BaseModel):
|
||||
keepOpen: bool = False
|
||||
|
||||
|
||||
class KnowledgeStatusRequest(BaseModel):
|
||||
open: bool
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.knowledge import (
|
||||
@@ -15,6 +15,7 @@ from app.models.knowledge import (
|
||||
KnowledgeChunk,
|
||||
KnowledgeManifest,
|
||||
KnowledgePublishLog,
|
||||
KnowledgeReviewRecord,
|
||||
KnowledgeSection,
|
||||
KnowledgeSourceSnapshot,
|
||||
KnowledgeSyncJob,
|
||||
@@ -24,7 +25,7 @@ from app.services.external_errors import ExternalServiceError
|
||||
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
|
||||
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_CHUNK_CHARS = 650
|
||||
|
||||
@@ -114,6 +115,8 @@ class KnowledgePipelineService:
|
||||
else:
|
||||
job.status = "unchanged"
|
||||
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.progress = 100
|
||||
job.finished_at = _now()
|
||||
@@ -305,7 +308,7 @@ class KnowledgePipelineService:
|
||||
keep_open: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Atomically activate a validated version and preserve rollback history."""
|
||||
"""Atomically replace the active version with validated latest content."""
|
||||
if version.quality_status == "blocked":
|
||||
raise ValueError("质检未通过的版本不能生效")
|
||||
if keep_open and knowledge.lifecycle_status != "active":
|
||||
@@ -313,17 +316,6 @@ class KnowledgePipelineService:
|
||||
if keep_open and knowledge.source_status != "normal":
|
||||
raise ValueError("飞书源文章异常,不能替换正式版本")
|
||||
previous_version_id = knowledge.current_version_id
|
||||
if previous_version_id:
|
||||
previous = db.get(KnowledgeVersion, previous_version_id)
|
||||
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.published_by = admin_id
|
||||
version.published_at = _now()
|
||||
@@ -347,34 +339,36 @@ class KnowledgePipelineService:
|
||||
),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
KnowledgePipelineService._prune_old_versions(db, knowledge, version)
|
||||
|
||||
@staticmethod
|
||||
def rollback(db: Session, knowledge: Knowledge, version: KnowledgeVersion, admin_id: int, keep_open: bool) -> None:
|
||||
if version.status not in {"published", "superseded"}:
|
||||
raise ValueError("只能回滚到历史正式版本")
|
||||
previous_id = knowledge.current_version_id
|
||||
if previous_id:
|
||||
previous = db.get(KnowledgeVersion, previous_id)
|
||||
if previous:
|
||||
previous.status = "superseded"
|
||||
db.add(previous)
|
||||
version.status = "published"
|
||||
knowledge.current_version_id = version.id
|
||||
knowledge.status = 1 if keep_open else 0
|
||||
knowledge.last_published_at = _now()
|
||||
db.add_all(
|
||||
[
|
||||
knowledge,
|
||||
version,
|
||||
KnowledgePublishLog(
|
||||
knowledge_id=knowledge.id,
|
||||
from_version_id=previous_id,
|
||||
to_version_id=version.id,
|
||||
action="rollback",
|
||||
keep_open=knowledge.status,
|
||||
operated_by=admin_id,
|
||||
),
|
||||
]
|
||||
def _prune_old_versions(db: Session, knowledge: Knowledge, keep: KnowledgeVersion) -> None:
|
||||
"""Keep only the active version and its derived retrieval data."""
|
||||
old_version_ids = list(db.scalars(
|
||||
select(KnowledgeVersion.id).where(
|
||||
KnowledgeVersion.knowledge_id == knowledge.id,
|
||||
KnowledgeVersion.id != keep.id,
|
||||
)
|
||||
).all())
|
||||
if old_version_ids:
|
||||
for model in (
|
||||
KnowledgeReviewRecord,
|
||||
KnowledgeCard,
|
||||
KnowledgeChunk,
|
||||
KnowledgeSection,
|
||||
KnowledgeManifest,
|
||||
):
|
||||
db.execute(delete(model).where(model.version_id.in_(old_version_ids)))
|
||||
db.execute(delete(KnowledgeVersion).where(KnowledgeVersion.id.in_(old_version_ids)))
|
||||
|
||||
# Snapshots are source copies for a version. Once that version is gone,
|
||||
# retaining the old snapshot would still be retaining knowledge history.
|
||||
db.execute(
|
||||
delete(KnowledgeSourceSnapshot).where(
|
||||
KnowledgeSourceSnapshot.knowledge_id == knowledge.id,
|
||||
KnowledgeSourceSnapshot.id != keep.snapshot_id,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
def test_rollback_switches_published_version_statuses():
|
||||
with _database() as db:
|
||||
item = _knowledge(db)
|
||||
old_version = db.get(KnowledgeVersion, item.current_version_id)
|
||||
snapshot = db.scalar(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == item.id))
|
||||
target = KnowledgeVersion(
|
||||
knowledge_id=item.id,
|
||||
snapshot_id=snapshot.id,
|
||||
version_no=0,
|
||||
status="superseded",
|
||||
quality_status="passed",
|
||||
processing_rule_version="test-v1",
|
||||
)
|
||||
db.add(target)
|
||||
db.flush()
|
||||
KnowledgePipelineService.rollback(db, item, target, admin_id=1, keep_open=True)
|
||||
db.commit()
|
||||
assert item.current_version_id == target.id
|
||||
assert target.status == "published"
|
||||
assert old_version.status == "superseded"
|
||||
|
||||
|
||||
def test_sync_automatically_replaces_current_version_and_keeps_rollback_history():
|
||||
def test_sync_automatically_replaces_current_version_and_removes_history():
|
||||
source_content = (
|
||||
"# 一、课程说明\n这是更新后的课程内容,同步成功后应当立即生效。\n\n"
|
||||
"# 二、练习一:静水流深静心\n选择舒适安全的姿势,觉察呼吸和身体,不适时立即停止。"
|
||||
@@ -174,7 +152,12 @@ def test_sync_automatically_replaces_current_version_and_keeps_rollback_history(
|
||||
|
||||
db.refresh(item)
|
||||
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))
|
||||
cards = db.scalars(select(KnowledgeCard).where(KnowledgeCard.version_id == current.id)).all()
|
||||
publish_log = db.scalar(
|
||||
@@ -185,7 +168,9 @@ def test_sync_automatically_replaces_current_version_and_keeps_rollback_history(
|
||||
assert job.mode == "replace"
|
||||
assert current.status == "published"
|
||||
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.status == 1
|
||||
assert item.migration_status == "completed"
|
||||
|
||||
Reference in New Issue
Block a user