P14第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

上传与后台任务

上传与后台任务 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这一课按本站资料库(/api/documents)的真实设计,用 Python 实现文档上传:先验大小和类型再落盘,用状态机记录解析进度和失败原因,失败可重试,删除时把文件和索引清干净。

问题场景

你要做自己的资料库:上传 markdown/PDF 文档,后台解析切分建索引,AI 助手检索时引用。要防的事很具体:用户传 200MB 的文件撑爆磁盘、传 exe 冒充文档、解析到一半服务重启后文档永远卡在「处理中」、删除文档后磁盘上还留着孤儿文件。本站的答案是一套状态机 + 检查前置 + 可重试设计,Python 侧逐点照做。

核心解释

上传接口的核心顺序:先检查,后落盘,再建任务。

import os
import uuid
from pathlib import Path
from fastapi import FastAPI, UploadFile, HTTPException, BackgroundTasks

app = FastAPI()
UPLOAD_DIR = Path("data/uploads")
ACCEPTED_EXT = [".md", ".txt", ".pdf"]
MAX_FILE_BYTES = 10 * 1024 * 1024                      # 10MB,与本站一致

@app.put("/documents")
async def upload(file: UploadFile, collection: str = "default",
                 tasks: BackgroundTasks = BackgroundTasks()):
    ext = Path(file.filename or "").suffix.lower()
    if ext not in ACCEPTED_EXT:
        raise HTTPException(400, detail=f"不支持的文件类型 {ext},支持:{' '.join(ACCEPTED_EXT)}")
    size = file.size or 0
    if size > MAX_FILE_BYTES:
        raise HTTPException(400, detail="文件超过 10MB 上限")

    stored = f"{uuid.uuid4().hex}{ext}"                # 存储名与原名解耦
    path = UPLOAD_DIR / stored
    path.write_bytes(await file.read())

    doc_id = f"doc_{uuid.uuid4().hex[:8]}"
    set_status(doc_id, "pending")                      # 1. 记 pending
    tasks.add_task(index_document, doc_id, path)       # 2. 后台解析
    return {"ok": True, "id": doc_id, "status": "pending"}

状态机与失败处理是这一课的灵魂,逐条对齐本站设计:

  1. 状态只有四值:pending → indexed 或 pending → failed。状态存 document.status,失败原因写 error 字段——本站两张表(document + index_job)分头记录,理由是失败原因必须可解释、可查询,而不是丢一句「失败了」。
  2. 失败不删文件、保留原因。解析失败(比如 PDF 编码不支持)时状态置 failed + 原因入库,前端能看到「为什么失败」并触发重试;重试就是重新跑一遍解析任务,而不是要求重新上传。
  3. BackgroundTasks 够用就别上重型队列。FastAPI 的 BackgroundTasks 在响应返回后执行函数,适合本机单人场景;真正的分布式队列(Celery)在数据规模到之前都是负债。
  4. 删除 = 级联三清:删数据库行、删切分索引、删磁盘文件(本站 DELETE 接口正是这个顺序)。落盘时用了随机存储名,删除时按 stored_name 字段找回,别用原始文件名拼路径——重名和路径穿越都从这来。

示例拆解

状态查询接口把整个链路串起来:

@app.get("/documents/{doc_id}")
def get_document(doc_id: str):
    doc = get_doc(doc_id)
    return {"id": doc_id, "status": doc["status"], "error": doc["error"],
            "chunks": doc["chunks"]}        # 前端轮询这个接口直到状态离开 pending

验证流程:上传一个 2MB 的 md 文件,立即 GET 查询应看到 pending,几秒后变 indexed 且 chunks 有值;再传一个 .exe,应直接 400 带出支持的类型列表;手动把解析函数改抛异常,重传后状态应停在 failed 且 error 里是异常信息——三个场景分别验证检查前置、状态推进、失败留痕。

动手任务

  1. 实现 retry_document(doc_id):把 failed 的文档重新放进 BackgroundTasks,状态回到 pending。
  2. 给上传加「同名覆盖检测」:同 collection 下同名文件已存在时返回 409,让调用方显式决定是否替换。
  3. 实现 DELETE /documents?id=...:按顺序清数据库行、索引、磁盘文件,并验证磁盘上不留孤儿文件。

常见错误

  • 先落盘再校验:超限文件已经写进磁盘,校验失败留下垃圾文件;大小和类型检查必须在 write 之前。
  • 用原始文件名存储:重名覆盖、路径穿越(../../)全来了;存储名随机化,原名只作展示。
  • 状态卡在 pending:进程重启后台任务就没了,没有重试机制的状态机是僵尸制造机;启动时把 pending 超时的文档标记 failed 或重排。
  • 删除只删数据库:文件和切分索引成为孤儿,磁盘慢慢被吃光;删除操作必须级联且验证。

检查与复盘

自查清单:

  • 大小/类型检查发生在落盘之前,非法文件不留痕
  • 每个文档随时能查到状态和失败原因,failed 可重试
  • 删除一个文档后,数据库、索引、磁盘三处都干净

参考资料

检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1单选中等

课程封面上传接口:前端传约 10MB 的图片,后端流程是「校验类型与大小 → 存到磁盘 → 写数据库记录」。下列说法正确的是?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...