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

@@ -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=""),
openStatus: int | None = Query(default=None),
sourceStatus: str = Query(default=""),
pending: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
@@ -51,8 +50,6 @@ def list_knowledge(
query = query.where(Knowledge.status == openStatus)
if 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
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))
@@ -119,7 +116,7 @@ def import_knowledge_from_space(
feishu_node_id=node_token,
status=0,
knowledge_type="general",
review_mode="manual",
review_mode="automatic",
remark=f"obj_type={info.get('obj_type', '')}",
)
db.add(knowledge)
@@ -153,7 +150,7 @@ def create_knowledge(
status=0,
remark=payload.remark,
knowledge_type=payload.knowledgeType,
review_mode=payload.reviewMode,
review_mode="automatic",
)
db.add(knowledge)
db.flush()
@@ -179,7 +176,7 @@ def update_knowledge(
knowledge.name = payload.name
knowledge.remark = payload.remark
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
knowledge.review_mode = "automatic"
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
db.commit()
@@ -243,13 +240,10 @@ def _knowledge_dict(knowledge: Knowledge) -> dict:
"remark": knowledge.remark,
"sourceTitle": knowledge.source_title,
"knowledgeType": knowledge.knowledge_type,
"reviewMode": knowledge.review_mode,
"lifecycleStatus": knowledge.lifecycle_status,
"sourceStatus": knowledge.source_status,
"sourceError": knowledge.source_error,
"manifestConfirmed": knowledge.manifest_confirmed == 1,
"currentVersionId": knowledge.current_version_id,
"pendingVersionId": knowledge.pending_version_id,
"lastSyncedAt": knowledge.last_synced_at,
"lastPublishedAt": knowledge.last_published_at,
"migrationStatus": knowledge.migration_status,

View File

@@ -23,10 +23,8 @@ from app.schemas.knowledge import (
KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest,
KnowledgeMetadataUpdateRequest,
KnowledgePublishRequest,
KnowledgeReviewRequest,
KnowledgeRollbackRequest,
KnowledgeStatusRequest,
KnowledgeSyncRequest,
)
from app.services.admin_service import OperationLogService
from app.services.knowledge_pipeline_service import KnowledgePipelineService
@@ -41,8 +39,8 @@ def batch_update_metadata(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
if payload.knowledgeType is None and payload.reviewMode is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少选择一个待修改字段")
if payload.knowledgeType is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="选择待修改的知识库类型")
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
rows = {
item.id: item
@@ -56,8 +54,6 @@ def batch_update_metadata(
continue
if payload.knowledgeType is not None:
item.knowledge_type = payload.knowledgeType
if payload.reviewMode is not None:
item.review_mode = payload.reviewMode
db.add(item)
OperationLogService.write(
db,
@@ -85,7 +81,7 @@ def update_metadata(
knowledge = _knowledge(db, knowledge_id)
knowledge.name = payload.name.strip()
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
knowledge.review_mode = "automatic"
db.add(knowledge)
OperationLogService.write(
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)
knowledge = _knowledge(db, knowledge_id)
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_version_id = knowledge.pending_version_id or knowledge.current_version_id
manifest_version_id = knowledge.current_version_id
if manifest_version_id:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
return api_success(
{
"knowledge": _knowledge_detail(knowledge),
"currentVersion": _version_dict(current),
"pendingVersion": _version_dict(pending),
"manifest": _manifest_dict(manifest),
}
)
@@ -206,7 +200,6 @@ def sync_jobs(
@router.post("/knowledge/{knowledge_id}/sync")
async def synchronize(
knowledge_id: int,
payload: KnowledgeSyncRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -216,7 +209,7 @@ async def synchronize(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
try:
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:
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))
@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")
def rollback(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
payload: KnowledgeRollbackRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -321,8 +253,6 @@ def open_status(
if payload.open:
if not knowledge.current_version_id:
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":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
if knowledge.lifecycle_status != "active":
@@ -381,7 +311,7 @@ async def run_migration(
for knowledge in rows:
try:
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)})
except Exception as exc:
@@ -428,14 +358,11 @@ def _knowledge_detail(item: Knowledge) -> dict:
"feishuSpaceId": item.feishu_space_id,
"feishuNodeId": item.feishu_node_id,
"knowledgeType": item.knowledge_type,
"reviewMode": item.review_mode,
"open": item.status == 1,
"lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status,
"sourceError": item.source_error,
"manifestConfirmed": item.manifest_confirmed == 1,
"currentVersionId": item.current_version_id,
"pendingVersionId": item.pending_version_id,
"lastSyncedAt": item.last_synced_at,
"lastPublishedAt": item.last_published_at,
"migrationStatus": item.migration_status,

View File

@@ -22,7 +22,7 @@ class Knowledge(Base, TimestampMixin):
remark: 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)
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)
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
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)
remark: str | None = Field(default=None, max_length=255)
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
reviewMode: str = Field(default="manual", pattern="^(manual|automatic)$")
class PromptSaveRequest(BaseModel):

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
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.models import Base
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.services.knowledge_agent_service import KnowledgeAgentService
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)
update_metadata(
item.id,
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course", reviewMode="automatic"),
KnowledgeMetadataUpdateRequest(name="新后台名称", knowledgeType="course"),
db,
_admin(),
)
@@ -98,7 +108,7 @@ def test_metadata_update_requires_super_admin():
with pytest.raises(HTTPException) as exc:
update_metadata(
item.id,
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa", reviewMode="manual"),
KnowledgeMetadataUpdateRequest(name="新名称", knowledgeType="qa"),
db,
_admin("operator"),
)
@@ -138,3 +148,76 @@ def test_rollback_switches_published_version_statuses():
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 = (
"# 一、课程说明\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