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

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

View File

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

View File

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

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