一次业务迭代:聊聊知识库多版本可追溯审核流程怎么做

你有没有遇到过这样的场景:

想看看之前版本的某个知识却找不到了,因为被新版本覆盖了

新上传的知识有重大缺陷,需要快速回退发现本机电脑上的旧版本被删除了,知识库也没有保留以前的版本。

某份文档明明是 A 上传的,却被 B 顺手提交、又被 A 自己点了"通过";

一个 Owner 觉得审核队列太乱,直接把别人提交的审核撤回了。

如果这些场景听起来有点熟悉,那你大概会理解:知识库文档从"上传后立即可用"升级为"可追溯版本 + 审核后发布",并不是加几个按钮那么简单。

这篇文章不是接口手册,而是一次改造复盘。我们会聊清楚两件事:

同一份逻辑文档如何保存多个版本,并保证历史版本可查看、可对比、可回滚。

上传人、提交人和审核人的职责如何区分,避免自己上传、自己审核,也避免其他协作者越权撤回。

一、功能闭环:从上传到发布

先整体流程来,清晰看懂整个文档生命周期:

其中"版本业务状态"和"文件处理状态"是两套独立状态:

类型 字段 示例 含义
版本业务状态 DocumentVersion.status draft、pending_review、published 是否可以提交、审核、发布或归档
处理状态 DocumentVersion.processing_status uploaded、processing、ready、failed 解析、切片和向量化是否完成

例如,刚上传的版本应显示为"草稿 / 待解析";解析完成后显示为"草稿 / 已完成",此时才出现"提交审核"。把两套状态分开,能够避免页面同时出现"待处理"和"处理完成"造成的歧义。

二、版本模型设计

2.1 版本快照而不是覆盖文档

documents 表表示逻辑文档,document_versions 表表示每次上传或编辑产生的内容快照。版本保存文件路径、哈希、解析文本、标签和审核信息,因此发布新版本不会覆盖旧版本。

python 复制代码
class DocumentVersionStatus(StrEnum):
    # 草稿可以继续编辑或提交审核
    DRAFT = "draft"
    # 已经提交,等待指定审核人处理
    PENDING_REVIEW = "pending_review"
    # 审核通过后的中间状态,当前发布流程会继续设置为 published
    APPROVED = "approved"
    # 当前可被检索和问答使用的版本
    PUBLISHED = "published"
    # 审核不通过,返回可修改的草稿状态
    REJECTED = "rejected"
    # 被新版本替代或主动归档的历史版本
    ARCHIVED = "archived"
    # 到达过期时间后的版本
    EXPIRED = "expired"


class DocumentVersion(UUIDPrimaryKeyMixin, TimestampMixin, Base):
    __tablename__ = "document_versions"
    __table_args__ = (
        # 同一份逻辑文档的版本号不能重复
        UniqueConstraint("document_id", "version_number"),
        # 同一份逻辑文档最多只能有一个当前版本
        Index(
            "uq_document_versions_current",
            "document_id",
            unique=True,
            postgresql_where=text("is_current = true"),
        ),
    )

    document_id: Mapped[UUID] = mapped_column(...)
    version_number: Mapped[int] = mapped_column(Integer)
    parent_version_id: Mapped[UUID | None] = mapped_column(...)
    original_name: Mapped[str] = mapped_column(String(255))
    storage_path: Mapped[str] = mapped_column(String(500))
    content_hash: Mapped[str] = mapped_column(String(64), index=True)
    status: Mapped[DocumentVersionStatus] = mapped_column(...)
    processing_status: Mapped[DocumentStatus] = mapped_column(...)

    # 三个参与者分别代表"创建版本的人""提交审核的人""实际审核的人"
    created_by: Mapped[UUID] = mapped_column(...)
    submitted_by: Mapped[UUID | None] = mapped_column(...)
    assigned_reviewer_id: Mapped[UUID | None] = mapped_column(...)
    reviewed_by: Mapped[UUID | None] = mapped_column(...)
    reviewed_at: Mapped[datetime | None] = mapped_column(...)
    review_comment: Mapped[str | None] = mapped_column(Text)
    is_current: Mapped[bool] = mapped_column(Boolean, default=False, index=True)

审核过程另外保存到 document_reviews,记录每次状态变化的前后状态、操作人、动作和备注。这样"当前状态"用于列表快速展示,"审核记录"用于审计和追溯,两者职责清晰。

2.2 上传即创建草稿,解析异步执行

上传接口只负责保存源文件和创建版本,解析任务放到后台执行。这样上传响应不会等待 PDF/DOCX 解析和向量化,也能明确禁止未处理完成的版本提交审核。

python 复制代码
async def create_document_from_upload(
    *, knowledge_base_id: UUID, file: UploadFile,
    session: DbSession, current_user: CurrentUser,
) -> Document:
    # 先校验扩展名、大小和空文件,避免无效文件进入异步任务
    original_name = Path(file.filename or "unnamed").name
    content = await file.read(settings.max_upload_size_bytes + 1)
    if not content:
        raise AppError("EMPTY_FILE", "不能上传空文件", 400)

    document = Document(
        knowledge_base_id=knowledge_base_id,
        original_name=original_name,
        storage_path=str(target_path.resolve()),
        content_hash=sha256_bytes(content),
        status=DocumentStatus.UPLOADED,
        publication_status=DocumentPublicationStatus.DRAFT,
        uploaded_by=current_user.id,
    )
    session.add(document)
    await session.flush()  # 先拿到 document.id,再建立版本外键

    # 首次上传统一从 v1 草稿开始,创建人就是当前登录用户
    session.add(
        DocumentVersion(
            document_id=document.id,
            version_number=1,
            original_name=original_name,
            storage_path=document.storage_path,
            content_hash=document.content_hash,
            status=DocumentVersionStatus.DRAFT,
            processing_status=DocumentStatus.UPLOADED,
            created_by=current_user.id,
        )
    )
    return document


@router.post("/knowledge-bases/{knowledge_base_id}/documents")
async def upload_document(..., background_tasks: BackgroundTasks):
    # 事务提交后再排队,后台任务读取到的一定是已提交数据
    document = await create_document_from_upload(...)
    await session.commit()
    background_tasks.add_task(process_document, document.id)
    return document

处理任务完成后只更新目标版本的 processing_status。当前版本不存在时才同步更新逻辑文档的可检索内容,避免新草稿解析过程中影响正在使用的已发布版本。

三. 审核流程与权限边界

3.1 审核人候选集

审核人只从当前知识库的其他 Owner 和系统管理员中选择,并排除创建者。前端弹窗加载候选集,后端提交接口再次校验,不能只依赖前端隐藏按钮。

python 复制代码
async def list_eligible_reviewers(
    session: DbSession, document: Document, creator_id: UUID
) -> list[User]:
    # 知识库 Owner 是资源级审核人
    owner_rows = await session.scalars(
        select(User)
        .join(KnowledgeBaseMember, KnowledgeBaseMember.user_id == User.id)
        .where(
            KnowledgeBaseMember.knowledge_base_id == document.knowledge_base_id,
            KnowledgeBaseMember.role == MemberRole.OWNER,
            User.id != creator_id,                 # 明确排除创建者,禁止自审
            User.is_active.is_(True),
        )
    )
    # 系统管理员可以跨知识库处理审核
    admin_rows = await session.scalars(
        select(User).where(
            User.system_role == SystemRole.ADMIN,
            User.id != creator_id,                 # 管理员如果就是创建者也不能自审
            User.is_active.is_(True),
        )
    )
    reviewers = {user.id: user for user in owner_rows}
    reviewers.update({user.id: user for user in admin_rows})
    return sorted(reviewers.values(), key=lambda item: item.display_name)

3.2 提交审核必须由创建者完成

协作者可以查看和编辑,但"谁上传/创建了该版本,谁才能提交审核"是版本级规则。这样可以避免 A 上传草稿、B 代提交后,A 又被当作普通审核人处理自己的版本。

python 复制代码
if version.status not in {DocumentVersionStatus.DRAFT, DocumentVersionStatus.REJECTED}:
    raise ConflictError("只有草稿或已驳回版本可以提交审核")

# 提交人必须是该版本创建者,而不是当前文档的任意 Owner
if version.created_by != current_user.id:
    raise ConflictError("只有创建该版本的用户可以提交审核")

# 解析未结束时没有可审阅的稳定内容
if version.processing_status != DocumentStatus.READY:
    raise ConflictError("只有处理完成的版本可以提交审核")

reviewers = await list_eligible_reviewers(session, document, version.created_by)
if payload.reviewer_id not in {item.id for item in reviewers}:
    raise ConflictError("审核人必须是创建者以外的 Owner 或系统管理员")

version.status = DocumentVersionStatus.PENDING_REVIEW
version.submitted_by = current_user.id
version.assigned_reviewer_id = payload.reviewer_id
await _add_review(session, version, current_user.id, "submit", previous, None)
await session.commit()

3.3 审核、驳回和撤回

审核接口使用统一的 _review_version,保证通过和驳回都先检查:版本必须是待审核、审核人不能是创建者、普通 Owner 必须是被分配的审核人。

python 复制代码
if version.status != DocumentVersionStatus.PENDING_REVIEW:
    raise ConflictError("只有待审核版本可以审核")
if version.created_by == current_user.id:
    raise ConflictError("审核人不能审核自己创建的版本")
if (
    current_user.system_role != SystemRole.ADMIN
    and version.assigned_reviewer_id != current_user.id
):
    raise ConflictError("该版本已分配给其他审核人")

if action == "approve":
    # 只有解析完成的版本才能成为正式发布版本
    if version.processing_status != DocumentStatus.READY:
        raise ConflictError("只有处理完成的版本可以发布")

    # 先取消旧版本的当前标记,释放部分唯一索引
    old_current = await session.scalars(
        select(DocumentVersion).where(
            DocumentVersion.document_id == document.id,
            DocumentVersion.is_current.is_(True),
        )
    )
    for old in old_current:
        old.is_current = False
        if old.status == DocumentVersionStatus.PUBLISHED:
            old.status = DocumentVersionStatus.ARCHIVED
    await session.flush()  # 避免新旧版本同时 is_current 导致唯一索引冲突

    version.status = DocumentVersionStatus.PUBLISHED
    version.is_current = True
    document.publication_status = DocumentPublicationStatus.PUBLISHED
else:
    # 驳回后回到可编辑状态,并保留审核备注和历史记录
    version.status = DocumentVersionStatus.REJECTED

await _add_review(session, version, current_user.id, action, previous, payload.comment)
await session.commit()

撤回与审核是不同动作:只有 pending_review 可以撤回,而且必须由提交审核的人操作。系统管理员也不能替提交人撤回,避免审核队列被他人误操作。

python 复制代码
if version.status != DocumentVersionStatus.PENDING_REVIEW:
    raise ConflictError("只有待审核版本可以撤回")
if version.submitted_by != current_user.id:
    raise ConflictError("只有提交审核的用户可以撤回")

version.status = DocumentVersionStatus.DRAFT
version.assigned_reviewer_id = None
await _add_review(session, version, current_user.id, "withdraw", previous, payload.comment)
await session.commit()

3.4 阻止未完成审核时继续创建版本

同一文档同时存在多个草稿或待审核版本,会造成"到底审核哪一份"的歧义。因此上传新版本前会检查活动版本:

python 复制代码
async def ensure_no_active_version_review(session: DbSession, document_id: UUID) -> None:
    # 草稿和待审核版本都属于未完成流程,先完成它们再创建新版本
    active_version = await session.scalar(
        select(DocumentVersion.version_number).where(
            DocumentVersion.document_id == document_id,
            DocumentVersion.status.in_([
                DocumentVersionStatus.DRAFT,
                DocumentVersionStatus.PENDING_REVIEW,
            ]),
        )
    )
    if active_version is not None:
        raise ConflictError(f"v{active_version} 尚未完成审核,暂不能创建新版本")

四. 前端版本操作设计

知识库文档页在每个版本行显示预览、下载、对比、编辑、提交审核、撤回审核、回滚和审核记录。按钮同时检查当前用户与版本字段,服务端仍做最终授权判断。

tsx 复制代码
{/* 只有版本创建者可以提交,且解析完成后按钮才可用 */}
{canEdit &&
  user?.id === version.created_by &&
  ['draft', 'rejected'].includes(version.status) && (
    <button
      disabled={version.processing_status !== 'ready'}
      title={
        version.processing_status !== 'ready'
          ? '处理完成后才能提交审核'
          : undefined
      }
      onClick={() => void startReviewSubmission(item, version)}
    >
      提交审核
    </button>
  )}

{/* 撤回按钮只对提交人显示,和后端 submitted_by 校验保持一致 */}
{canEdit &&
  user?.id === version.submitted_by &&
  version.status === 'pending_review' && (
    <button onClick={() => void performLifecycleAction(item, 'withdraw', version)}>
      撤回审核
    </button>
  )}

上传接口只返回文档元数据,不直接携带版本详情。为解决"新上传后看不到提交审核入口"的问题,批量上传完成后前端主动打开首个文档的版本数据;后台解析完成后,轮询刷新使"提交审核"按钮自动可用。

五. 问题复盘

5.1 新上传后一直是草稿,没有提交入口

原因 :上传接口返回的是逻辑文档,版本列表需要额外请求;同时后台解析是异步的,初次加载时版本还处于 uploaded/processing。

解决:上传成功后主动加载首个文档的版本列表;版本行只对创建者显示按钮;解析完成前禁用按钮并展示原因,轮询检测处理状态后自动刷新。

5.2 审核通过时报 Internal Server Error

原因 :数据库对 document_id + is_current=true 建了部分唯一索引。通过新版本时,如果先把新版本设为当前,旧版本仍占用唯一索引,事务会失败。

解决 :先把旧当前版本改为非当前并归档,然后 flush() 让数据库立即释放唯一索引,最后再发布新版本。

5.3 版本 2 没有通过就可以继续上传版本 3

原因:只校验了"是否有待审核版本",没有把草稿也视为未完成流程。

解决 :统一检查 DRAFT 和 PENDING_REVIEW,任何一个存在时都拒绝创建新版本;驳回或撤回回到草稿后,仍需先修改并重新完成流程。

5.4 其他 Owner 可以代提交或撤回

原因 :早期只按知识库角色判断"有编辑权限即可操作",没有使用版本上的 created_by 和 submitted_by。

解决 :提交审核严格比较 created_by,撤回严格比较 submitted_by;前端隐藏无权按钮,后端始终再次校验。

5.5 "待处理"和"处理完成"同时出现

原因:页面把业务审核状态和异步解析状态混在一个标签里理解。

解决:分为"版本状态"和"解析状态"两个字段展示,并在提交按钮上明确"解析完成后才能提交审核"。

六、落地总结与后续优化方向

6.1 核心收获

  1. 复杂业务必拆分状态:单一状态字段极易产生歧义,拆分维度后,逻辑、展示、校验都会更清晰

  2. 权限必须后端闭环:前端显隐只是体验优化,核心操作必须基于数据字段做精准校验,杜绝越权风险

  3. 数据库约束要兼顾业务边界:唯一索引、事务执行顺序,必须贴合业务流转逻辑,避免线上报错

  4. 异步解耦提升体验:耗时任务后台化,既优化接口响应,又方便做失败重试、状态管控

6.2 后续迭代方向

  • 新增版本 Diff 对比,支持文本、元数据逐行差异查看

  • 优化异步任务:将 BackgroundTasks 替换为可重试任务队列,提升稳定性

  • 丰富审核能力:新增审核超时、催办、批量审核功能

  • 增加幂等性保护,避免重复提交、重复审核问题

写在最后

本次知识库迭代,本质是 从"快速上线的裸奔功能"到"规范可控的工程化能力" 的升级。看似只是简单的版本+审核功能,实则涵盖了状态设计、权限管控、事务处理、异步解耦等多个后端核心知识点。

文章方案为项目实战落地经验,并非绝对最优解,欢迎各位大佬评论区交流探讨,一起进步💪

相关推荐
呆萌很1 小时前
长尾分类(Long-tailed Classification)方法汇总
人工智能·分类
for_ever_love__1 小时前
LangChain Agentic RAG:让 Agent 自己决定要不要检索、检索几次
人工智能·golang·langchain·rag·langgraph·agentic·自适应检索
Fxrain1 小时前
[Paper-Reading] CUAs 安全与隐私威胁综述
人工智能·安全·agent·智能体
乱世军军1 小时前
安装最新版本
人工智能
Mickey同学1 小时前
大模型应用的运行原理:Token、上下文、采样、成本与工程边界
人工智能
海边捡石子1 小时前
向量、图谱还是文件:Agent 记忆怎么选
人工智能·llm·agent
tiger从容淡定是人生1 小时前
从 Gemini 的任务示例出发:AI 能否从知识中推导工作流?
java·人工智能·aigc·知识图谱·创业创新·零知识证明
向哆哆1 小时前
外墙裂缝目标检测数据集:6,000+张图像 | 目标检测
人工智能·目标检测·计算机视觉
鶴哥只手遮天1 小时前
第 06 章 SQLite 持久化契约与数据模型
人工智能·开源