fix: 补齐知识库元数据管理与生命周期校验
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -24,6 +25,7 @@ from app.models.knowledge import (
|
||||
from app.schemas.admin import KnowledgeSaveRequest
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.feishu_service import FeishuKnowledgeService
|
||||
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -40,6 +42,7 @@ def import_knowledge_from_space(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
node_token = payload.get("node_token", "").strip()
|
||||
if not node_token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空")
|
||||
@@ -61,7 +64,7 @@ def import_knowledge_from_space(
|
||||
name=info.get("title", node_token),
|
||||
feishu_space_id=space_id,
|
||||
feishu_node_id=node_token,
|
||||
status=1,
|
||||
status=0,
|
||||
knowledge_type="general",
|
||||
review_mode="manual",
|
||||
remark=f"obj_type={info.get('obj_type', '')}",
|
||||
@@ -71,8 +74,13 @@ def import_knowledge_from_space(
|
||||
OperationLogService.write(
|
||||
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id
|
||||
)
|
||||
db.commit()
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||
db.refresh(knowledge)
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name})
|
||||
|
||||
|
||||
@@ -82,11 +90,20 @@ def create_knowledge(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
existing = db.scalar(
|
||||
select(Knowledge).where(
|
||||
Knowledge.feishu_space_id == payload.feishuSpaceId,
|
||||
Knowledge.feishu_node_id == payload.feishuNodeId,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
|
||||
knowledge = Knowledge(
|
||||
name=payload.name,
|
||||
feishu_space_id=payload.feishuSpaceId,
|
||||
feishu_node_id=payload.feishuNodeId,
|
||||
status=payload.status,
|
||||
status=0,
|
||||
remark=payload.remark,
|
||||
knowledge_type=payload.knowledgeType,
|
||||
review_mode=payload.reviewMode,
|
||||
@@ -94,8 +111,13 @@ def create_knowledge(
|
||||
db.add(knowledge)
|
||||
db.flush()
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
|
||||
db.commit()
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||
db.refresh(knowledge)
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_dict(knowledge))
|
||||
|
||||
|
||||
@@ -106,11 +128,9 @@ def update_knowledge(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _get_knowledge(db, knowledge_id)
|
||||
knowledge.name = payload.name
|
||||
knowledge.feishu_space_id = payload.feishuSpaceId
|
||||
knowledge.feishu_node_id = payload.feishuNodeId
|
||||
knowledge.status = payload.status
|
||||
knowledge.remark = payload.remark
|
||||
knowledge.knowledge_type = payload.knowledgeType
|
||||
knowledge.review_mode = payload.reviewMode
|
||||
@@ -118,6 +138,7 @@ def update_knowledge(
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
||||
db.commit()
|
||||
db.refresh(knowledge)
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_dict(knowledge))
|
||||
|
||||
|
||||
@@ -127,7 +148,13 @@ def delete_knowledge(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _get_knowledge(db, knowledge_id)
|
||||
if knowledge.lifecycle_status != "archived" or knowledge.status != 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="物理删除前必须先关闭并归档知识库",
|
||||
)
|
||||
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
|
||||
permissions = db.scalars(
|
||||
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
|
||||
@@ -146,6 +173,7 @@ def delete_knowledge(
|
||||
db.delete(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
|
||||
db.commit()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success()
|
||||
|
||||
|
||||
@@ -156,6 +184,11 @@ def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
|
||||
return knowledge
|
||||
|
||||
|
||||
def _require_super_admin(admin: Admin) -> None:
|
||||
if admin.role is not None and admin.role.code != "super_admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以管理知识库")
|
||||
|
||||
|
||||
def _knowledge_dict(knowledge: Knowledge) -> dict:
|
||||
return {
|
||||
"id": knowledge.id,
|
||||
|
||||
@@ -21,6 +21,8 @@ from app.models.knowledge import (
|
||||
)
|
||||
from app.schemas.knowledge import (
|
||||
KnowledgeLifecycleRequest,
|
||||
KnowledgeBatchMetadataUpdateRequest,
|
||||
KnowledgeMetadataUpdateRequest,
|
||||
KnowledgePublishRequest,
|
||||
KnowledgeReviewRequest,
|
||||
KnowledgeStatusRequest,
|
||||
@@ -28,10 +30,74 @@ from app.schemas.knowledge import (
|
||||
)
|
||||
from app.services.admin_service import OperationLogService
|
||||
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||
from app.services.knowledge_catalog_cache_service import KnowledgeCatalogCacheService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.patch("/knowledge/batch/metadata")
|
||||
def batch_update_metadata(
|
||||
payload: KnowledgeBatchMetadataUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
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="请至少选择一个待修改字段")
|
||||
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||
rows = {
|
||||
item.id: item
|
||||
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
|
||||
}
|
||||
results: list[dict] = []
|
||||
for knowledge_id in knowledge_ids:
|
||||
item = rows.get(knowledge_id)
|
||||
if item is None:
|
||||
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
|
||||
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,
|
||||
admin_id=current_admin.id,
|
||||
module="knowledge",
|
||||
action="batch_update_metadata",
|
||||
target_id=item.id,
|
||||
)
|
||||
results.append({"knowledgeId": knowledge_id, "ok": True})
|
||||
db.commit()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
success = sum(1 for item in results if item["ok"])
|
||||
return api_success(
|
||||
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/knowledge/{knowledge_id}/metadata")
|
||||
def update_metadata(
|
||||
knowledge_id: int,
|
||||
payload: KnowledgeMetadataUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
knowledge.name = payload.name.strip()
|
||||
knowledge.knowledge_type = payload.knowledgeType
|
||||
knowledge.review_mode = payload.reviewMode
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(
|
||||
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(knowledge)
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@router.get("/knowledge/{knowledge_id}/detail")
|
||||
def detail(
|
||||
knowledge_id: int,
|
||||
@@ -159,6 +225,7 @@ async def synchronize(
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
|
||||
db.commit()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_job_dict(job))
|
||||
|
||||
|
||||
@@ -171,6 +238,8 @@ def confirm_manifest(
|
||||
) -> 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:
|
||||
@@ -190,6 +259,8 @@ def review(
|
||||
) -> 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)
|
||||
@@ -208,12 +279,15 @@ def publish(
|
||||
) -> 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()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@@ -227,12 +301,17 @@ def rollback(
|
||||
) -> 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()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@@ -258,6 +337,7 @@ def open_status(
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
|
||||
db.commit()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@@ -270,12 +350,15 @@ def lifecycle(
|
||||
) -> dict:
|
||||
_require_super_admin(current_admin)
|
||||
knowledge = _knowledge(db, knowledge_id)
|
||||
if knowledge.lifecycle_status == payload.status:
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
knowledge.lifecycle_status = payload.status
|
||||
if payload.status == "archived":
|
||||
knowledge.status = 0
|
||||
db.add(knowledge)
|
||||
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
|
||||
db.commit()
|
||||
KnowledgeCatalogCacheService.invalidate()
|
||||
return api_success(_knowledge_detail(knowledge))
|
||||
|
||||
|
||||
@@ -347,6 +430,8 @@ def _knowledge_detail(item: Knowledge) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"status": item.status,
|
||||
"remark": item.remark,
|
||||
"sourceTitle": item.source_title,
|
||||
"feishuSpaceId": item.feishu_space_id,
|
||||
"feishuNodeId": item.feishu_node_id,
|
||||
@@ -362,6 +447,8 @@ def _knowledge_detail(item: Knowledge) -> dict:
|
||||
"lastSyncedAt": item.last_synced_at,
|
||||
"lastPublishedAt": item.last_published_at,
|
||||
"migrationStatus": item.migration_status,
|
||||
"createdAt": item.created_at,
|
||||
"updatedAt": item.updated_at,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
class Knowledge(Base, TimestampMixin):
|
||||
__tablename__ = "sys_knowledge"
|
||||
__table_args__ = (UniqueConstraint("feishu_space_id", "feishu_node_id", name="uq_knowledge_feishu_node"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class AiRequestLog(Base):
|
||||
__tablename__ = "sys_ai_request_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
@@ -32,7 +34,7 @@ class AiRequestLog(Base):
|
||||
class OperationLog(Base):
|
||||
__tablename__ = "sys_operation_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
@@ -24,6 +24,18 @@ class KnowledgeLifecycleRequest(BaseModel):
|
||||
status: str = Field(pattern="^(active|archived)$")
|
||||
|
||||
|
||||
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):
|
||||
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
|
||||
|
||||
class KnowledgeCatalogCacheService:
|
||||
"""Central invalidation point for current and future KnowledgeList caches."""
|
||||
|
||||
VERSION_KEY = "knowledge:catalog:version"
|
||||
|
||||
@classmethod
|
||||
def invalidate(cls) -> None:
|
||||
client = get_sync_redis_client()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.incr(cls.VERSION_KEY)
|
||||
except Exception:
|
||||
# The catalog currently reads MySQL on every request, so Redis failure
|
||||
# must not make a committed metadata change look unsuccessful.
|
||||
return
|
||||
@@ -210,7 +210,7 @@ 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 change_level == "minor" else 0,
|
||||
confirmed=1 if previous_manifest and previous_manifest.confirmed == 1 and change_level == "minor" else 0,
|
||||
)
|
||||
db.add(manifest)
|
||||
|
||||
@@ -236,6 +236,26 @@ class KnowledgePipelineService:
|
||||
{"versionId": version.id, "versionNo": version.version_no, **quality}, ensure_ascii=False
|
||||
)
|
||||
job.finished_at = _now()
|
||||
if (
|
||||
knowledge.review_mode == "automatic"
|
||||
and version.quality_status == "passed"
|
||||
and not quality["highRisk"]
|
||||
and manifest.confirmed == 1
|
||||
):
|
||||
cls.review_version(
|
||||
db,
|
||||
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
|
||||
@@ -299,6 +319,10 @@ class KnowledgePipelineService:
|
||||
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
||||
if manifest is None or manifest.confirmed != 1:
|
||||
raise ValueError("知识库说明卡尚未确认")
|
||||
if keep_open and knowledge.lifecycle_status != "active":
|
||||
raise ValueError("归档知识库不能在发布后开放")
|
||||
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)
|
||||
@@ -334,12 +358,19 @@ class KnowledgePipelineService:
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user