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

想看看之前版本的某个知识却找不到了,因为被新版本覆盖了
新上传的知识有重大缺陷,需要快速回退发现本机电脑上的旧版本被删除了,知识库也没有保留以前的版本。
某份文档明明是 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 核心收获
-
复杂业务必拆分状态:单一状态字段极易产生歧义,拆分维度后,逻辑、展示、校验都会更清晰
-
权限必须后端闭环:前端显隐只是体验优化,核心操作必须基于数据字段做精准校验,杜绝越权风险
-
数据库约束要兼顾业务边界:唯一索引、事务执行顺序,必须贴合业务流转逻辑,避免线上报错
-
异步解耦提升体验:耗时任务后台化,既优化接口响应,又方便做失败重试、状态管控
6.2 后续迭代方向
-
新增版本 Diff 对比,支持文本、元数据逐行差异查看
-
优化异步任务:将 BackgroundTasks 替换为可重试任务队列,提升稳定性
-
丰富审核能力:新增审核超时、催办、批量审核功能
-
增加幂等性保护,避免重复提交、重复审核问题
写在最后
本次知识库迭代,本质是 从"快速上线的裸奔功能"到"规范可控的工程化能力" 的升级。看似只是简单的版本+审核功能,实则涵盖了状态设计、权限管控、事务处理、异步解耦等多个后端核心知识点。
文章方案为项目实战落地经验,并非绝对最优解,欢迎各位大佬评论区交流探讨,一起进步💪