🏠 总目录📚 本教程 07b · 文件上传与对象存储
📑 本页目录(点开跳转)

07b · 文件上传与对象存储:从 413 到预签名直传

85 分钟 | ⭐⭐ 文件不该流过你的后端,你只该发一张通行证


🎯 一句话

上传的正确形状是:浏览器把文件直接交给对象存储,你的后端只负责发一张限时、限权、限大小的通行证 —— 全程不碰字节流。 剩下的难点全在这个形状的外围:413 到底是哪一层给的、传完之后你怎么知道、以及那个文件接下来会被模型读到。


🧩 一、这一章补的是哪个洞

第 7 章第五节画了一张流程图,第一步是这样的:

上传文档 → 切分 → 【算 embedding】→ 连同文本一起入库

⚠️ 「上传文档」这四个字,在那一章只出现了这一次。 后面三步讲得很细 —— 怎么建表、用哪个距离运算符、索引什么时候建、换模型要付什么代价 —— 但整条链的第一个箭头是空的

第 17 章的项目 ② 「文档问答」把这个洞摆得更明显:它的验收项里写着「上传后页面立刻返回」「同一个文档上传两次不会存两份 embedding」,⚠️ 而板块里没有任何一章讲过文件是怎么进来的

这一章就是那个箭头。 它到 chunks 表为止,切分策略、embedding、pgvector 选型全都归第 7 章,本章不碰。


🚧 二、为什么不能让文件流过你的后端

最自然的写法是加一个收文件的路由,读进来、存下来。它能跑,然后会在四个地方一起出事

出事的地方 具体是什么
内存 框架常见的默认行为是先把请求体读进内存(超过某个阈值才落临时文件)。一个人传 200 MB 还好,⚠️ 十个人同时传就是十份
磁盘 落到临时文件也一样:你的应用容器通常只有很小的可写空间,⚠️ 传满了不是「上传失败」,是整个进程开始报磁盘满
请求时长 上传耗时 = 文件大小 ÷ 用户的上行带宽。⚠️ 家用宽带上下行不对称,上行往往差一个量级 —— 你本地测的那个 3 页 PDF 永远发现不了这件事
⭐⭐ worker 被占住 传文件的那几十秒里,这个 worker 什么别的活都干不了。⚠️ 你的扩容维度当场错了:为了让人能传文件,你要给整个 API 加机器

判据一句话只要一个请求的耗时由「用户的网速」决定,它就不该占着你的应用进程。 这和第 11 章那条「耗时由外部决定的活别在请求里做完」是同一句话的两个方向 —— 那边是做得慢,这边是传得慢

⚠️⚠️ 413 是怎么来的:两个上限,两个都要调

这是本节最容易困住人的一件事。413 Payload Too Large 至少有两个来源,症状一模一样

哪一层 谁在拦 ⭐ 怎么认出是它
反向代理 / 入口网关 🗓️ Nginx 的 client_max_body_size(默认就是 1 MB 这个量级,第 14 章第五节那段配置里有它) ⭐⭐ 你的应用日志里根本没有这次请求 —— 请求在到你之前就被顶回去了
应用 / 框架 框架或你自己写的体积检查 日志里有这次请求,是你自己返回的 413

⚠️⚠️ 只调一个照样 413,而且改完之后「还是不行」会让人怀疑自己没改对 —— ⭐ 区分它们的判据就是上面那一列:去看应用日志里有没有这次请求。

💀 还有第三个位置,而且你可能改不了它:托管平台的入口网关有自己的上限,文档里未必写清楚。 ⭐ 这件事本身就是「别让文件流过后端」最有力的论据 —— 你要调的上限有三个,其中一个不归你管; 而走下一节那条路,这三个上限一个都不用碰,因为文件根本不经过它们。


🎫 三、⭐⭐ 预签名直传:后端只发通行证

形状是这样的,五步,文件本体只出现在第 ③ 步

谁 → 谁 干什么
浏览器 → 你的后端 POST /uploads,报上文件名、大小、类型
你的后端 鉴权、查配额、自己生成存储键、签一张通行证、在 uploads 表插一行 status='pending'
浏览器 → 对象存储 ⭐⭐ 文件本体直接传过去,不经过你
浏览器 → 你的后端 POST /uploads/{id}/complete:「我传完了」
你的后端 去存储核实一次,然后入队(第四节)

⭐⭐ 为什么这是对的形状,三条,每条都对应上一节的一个毛病:

⚠️ 它换来三个新问题,正好是后面三节:后端怎么知道传完了(第四节)、怎么防止用户传不该传的(第六节)、大小和类型怎么校验(就是下面这段)。

🔑 通行证长什么形状:限制必须写进签名里

各家对象存储的签名算法不一样(🗓️ 而且会随版本变,动手前查你那家的文档), ⭐ 但它们是同一个形状把一组约束序列化 → 用只有服务端知道的密钥签名 → 存储端验签之后照着这组约束执行

下面这段用 hmac 把这个形状当场跑出来,它不是任何一家的真实协议,但验签、过期、体积上限、类型限制这四件事的逻辑就是这样

import base64, hashlib, hmac, json, time

SECRET = b"only-the-server-has-this"      # ⭐ 只存在于服务端(第 13 章)


def _pack(policy: dict) -> str:
    raw = json.dumps(policy, sort_keys=True, separators=(",", ":")).encode()
    return base64.urlsafe_b64encode(raw).decode()


def issue(key, max_bytes, content_type, ttl=300, now=None):
    """签一张通行证:⭐ 限制写【进】被签名的那段内容里,不是写在前端。"""
    policy = {"key": key, "max_bytes": max_bytes,
              "content_type": content_type, "exp": int((now or time.time()) + ttl)}
    b64 = _pack(policy)
    sig = hmac.new(SECRET, b64.encode(), hashlib.sha256).hexdigest()[:32]
    return b64, sig


def verify(b64, sig, actual_bytes, actual_type, now=None):
    expect = hmac.new(SECRET, b64.encode(), hashlib.sha256).hexdigest()[:32]
    if not hmac.compare_digest(expect, sig):        # ⭐ 常数时间比较
        return "拒绝:签名对不上(策略被改过)"
    p = json.loads(base64.urlsafe_b64decode(b64))
    if (now or time.time()) > p["exp"]:
        return "拒绝:通行证过期"
    if actual_bytes > p["max_bytes"]:
        return f"拒绝:太大 {actual_bytes} > {p['max_bytes']}"
    if actual_type != p["content_type"]:
        return f"拒绝:类型不符 {actual_type} != {p['content_type']}"
    return "放行"


NOW = 1_700_000_000
b64, sig = issue("u/42/9f3c.pdf", 10 * 1024 * 1024, "application/pdf", now=NOW)

print("① 正常上传   ", verify(b64, sig, 3 * 1024 * 1024, "application/pdf", now=NOW + 10))
print("② 传了 99MB  ", verify(b64, sig, 99 * 1024 * 1024, "application/pdf", now=NOW + 10))
print("③ 换成 png   ", verify(b64, sig, 1024, "image/png", now=NOW + 10))
print("④ 一小时后   ", verify(b64, sig, 1024, "application/pdf", now=NOW + 3600))

# ⭐⭐ 关键的一问:攻击者把上限从 10MB 改成 999MB,再拿原来那个签名来用
tampered = _pack({"key": "u/42/9f3c.pdf", "max_bytes": 999 * 1024 * 1024,
                  "content_type": "application/pdf", "exp": NOW + 300})
print("⑤ 篡改上限   ", verify(tampered, sig, 99 * 1024 * 1024, "application/pdf", now=NOW + 10))

跑出来是这五行:

① 正常上传    放行
② 传了 99MB   拒绝:太大 103809024 > 10485760
③ 换成 png    拒绝:类型不符 image/png != application/pdf
④ 一小时后    拒绝:通行证过期
⑤ 篡改上限    拒绝:签名对不上(策略被改过)

⭐⭐ 第 ⑤ 行才是这段代码存在的理由:上限是被签进去的,改了它签名就对不上。 ⚠️ 反过来说:如果你只在前端 JS 里判断「超过 10 MB 就不让选」,那等于没有上限 —— 浏览器里的判断,攻击者按一次 F12 就没了。⭐ 凡是要守住的约束,都必须由服务端签进那张证里。

⚠️ 另外三条,都是签证时就要定的:


📬 四、传完之后:怎么接上队列

⚠️ 第 ③ 步是浏览器和存储之间的事,你不在场。 所以「传完了」这件事,你只能通过两条路知道,而且两条都不可靠

好处 ⚠️ 问题
客户端回调 /complete 简单、立刻 ⚠️ 客户端可能不调(关页面、断网、传到一半走人),也可能骗你(没传就说传了)
存储侧事件通知 存储真的收到对象才发,事实性强 要额外配置、有延迟、⚠️ 可能重复投递(所以它天然需要幂等,第 11 章第四节)

⭐⭐ 所以两条都不是「知道」,都要配一个兜底。 兜底就是 uploads 表那一列状态:

pending ──/complete 或事件通知──▶ ready ──入队──▶ processing ──▶ indexed
   │                                                              │
   └── 超过 N 分钟没动静 ──▶ 后台扫一遍:去存储 HEAD 一下 ────────┘
                              在 → ready | 不在 → expired

这个「扫一遍卡住的行」和第 11 章那条「把卡在 running 超过 N 分钟的 job 改回 queued」是同一个形状 —— 凡是状态机的一步依赖外部世界告诉你,就一定要有一个不依赖它的兜底。

⚠️⚠️ 不管走哪条路,服务端都必须自己去存储核实一次HEAD 拿真实的大小、类型、etag): /complete 里客户端报的数字,和它一开始报的那个一样不可信。

🔗 把 07 章那条链补完整

核实通过之后,入队第 11 章),整条链就接上了:

上传(本章)→ uploads.status = ready
  → 入队一个 kind='ingest' 的 job(第 11 章的 jobs 表)
    → worker:从对象存储拉回文件 → 切分 → 算 embedding → 写 chunks(第 7 章)
      → uploads.status = indexed

注意文件本体从头到尾没进过你的 web 进程 —— 只有 worker 拉了一次, 而 worker 慢一点、卡一下、被重启,都不影响任何一个用户的 HTTP 请求。 这正是第 7 章那句「算 embedding 是几十秒的活,必须走队列」落到地上的样子。

幂等键怎么取:第 11 章的规矩是「由输入决定」。这里的输入不是上传时间、也不是 upload id, 而是 文件内容的 hash + embedding 模型名

⚠️ 这一条正好兑现第 17 章项目 ② 的那条验收项:同一个文档上传两次,不会存两份 embedding。 (幂等的机制本身在第 11 章第四节,那里有完整可跑的实现,本章不重复。)


🧱 五、大文件分片与断点续传:什么时候需要

这一节给判据,不教协议 —— 各家的分片接口长得不一样(🗓️ 查文档),但要不要用它是可以先想清楚的。

分片上传就三段:初始化 → 一片一片传(可并发)→ 告诉存储「拼起来」。 断点续传是它的直接结果:已经传成功的片不用重传,客户端记住哪些片完成了就行。

该上分片的信号 为什么
单个文件到了几百 MB 以上 一次 PUT 失败的代价 = 整个文件重传
用户网络不稳(移动网络、跨境) 传到 90% 断了要从头来,用户会直接放弃
🗓️ 存储对单次上传有硬上限 各家都有一个「单次 PUT 最大值」,超了只能走分片

⚠️ 反过来更重要:几 MB 到几十 MB 的 PDF 和图片,不要上分片。 它带来三样东西:

  1. 三段式协议,客户端复杂度翻倍
  2. 每片的重试、并发数、顺序都要你自己管
  3. 💀 没走到「拼起来」那一步的残片会一直留在存储里占着钱,而且在对象列表里看不见 —— 要专门配一条「N 天后清理未完成的分片上传」的生命周期规则。⚠️ 这条是最容易漏的一条, 漏了就是一笔持续增长、没人知道来源的账。

判据压成一句先问「重传一次的代价能不能接受」,不能接受才上分片。


🛑 读到这里可以停 —— 前半章讲完了(约 31 分钟)。 后半章还有:安全:三样不能信的东西 · 存储成本与生命周期 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。


🛡️ 六、安全:三样不能信的东西

① ⚠️ 客户端说的类型和扩展名,是用户输入,不是事实

Content-Type 是请求头里的一个字符串,扩展名是文件名里的几个字符 —— 两个都由对方随手写。 要判类型,得去读文件开头那几个字节(magic bytes):

MAGIC = [
    (b"%PDF-",             "application/pdf"),
    (b"\x89PNG\r\n\x1a\n", "image/png"),
    (b"\xff\xd8\xff",      "image/jpeg"),
    (b"GIF87a",            "image/gif"),
    (b"GIF89a",            "image/gif"),
    (b"PK\x03\x04",        "application/zip"),   # ⚠️ docx/xlsx/pptx 全是这个头
    (b"MZ",                "application/x-msdownload"),
]

DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"


def sniff(head: bytes) -> str:
    for sig, mime in MAGIC:            # ⭐ 只看头几个字节:不看扩展名,也不看客户端说了什么
        if head.startswith(sig):
            return mime
    return "application/octet-stream"


def agrees(claimed: str, real: str) -> bool:
    if claimed == real:
        return True
    # ⚠️ docx/xlsx 本身就是 zip,magic bytes 只能确认"它是个 zip",确认不到更细
    return real == "application/zip" and claimed == DOCX


cases = [
    ("report.pdf", "application/pdf", b"%PDF-1.7\n%\xe2\xe3\xcf\xd3"),
    ("avatar.png", "image/png",       b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"),
    ("photo.pdf",  "application/pdf", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"),
    ("setup.pdf",  "application/pdf", b"MZ\x90\x00\x03\x00\x00\x00"),
    ("note.pdf",   "application/pdf", b"hello, i am just text"),
    ("data.docx",  DOCX,              b"PK\x03\x04\x14\x00\x06\x00"),
]

for name, claimed, head in cases:
    real = sniff(head)
    print(f"{name:11}客户端说={claimed[:20]:22}实读={real:26}"
          f"{'一致' if agrees(claimed, real) else '⚠️ 对不上,拒'}")

输出:

report.pdf 客户端说=application/pdf       实读=application/pdf           一致
avatar.png 客户端说=image/png             实读=image/png                 一致
photo.pdf  客户端说=application/pdf       实读=image/png                 ⚠️ 对不上,拒
setup.pdf  客户端说=application/pdf       实读=application/x-msdownload  ⚠️ 对不上,拒
note.pdf   客户端说=application/pdf       实读=application/octet-stream  ⚠️ 对不上,拒
data.docx  客户端说=application/vnd.open  实读=application/zip           一致

setup.pdf 那一行是重点:一个 Windows 可执行文件改名叫 .pdf,扩展名和 Content-Type 都在骗你,头两个字节骗不了。 ⚠️ 但别把它当成「检测恶意」data.docx 那一行说明 magic bytes 只能确认「它是个 zip」; ⭐ 而且类型对,不代表内容安全 —— 一个格式完全合法的 PDF 里照样能藏东西(就是下面第 ③ 条)。 ⭐ 所以判据是「白名单 + 头字节复核」,不是「查杀」。

② ⭐⭐ 用户给的文件名,根本不该被当成路径

经典的路径穿越是 ../../etc/passwd。很多人的第一反应是「那我把它洗干净」:

import os, re, unicodedata, uuid


def safe_display_name(raw: str, fallback: str = "file") -> str:
    name = unicodedata.normalize("NFKC", raw)        # ⭐ 先规范化,挡掉 Unicode 变体写法
    name = name.replace("\x00", "")                  # ⚠️ 空字节能在 C 层把路径截断
    name = name.replace("\\", "/").split("/")[-1]    # ⭐ 只取最后一段,目录部分整段丢掉
    name = re.sub(r"[^A-Za-z0-9._\u4e00-\u9fff -]", "_", name)
    name = name.lstrip(".")                          # 防 ".." 和以点开头的隐藏文件
    return name[:120] or fallback


def object_key(user_id: int, raw_name: str) -> str:
    # ⭐⭐ 真正落到存储上的路径由【你】生成,用户给的字符串只进数据库的一列
    ext = os.path.splitext(safe_display_name(raw_name))[1].lower()
    ext = ext if re.fullmatch(r"\.[a-z0-9]{1,8}", ext or "") else ""
    return f"u/{user_id}/{uuid.uuid4().hex}{ext}"


for raw in ["../../etc/passwd", r"C:\Windows\system32\config\sam", "..\\..\\.env",
            "正常文档.pdf", "a\x00.png", "....//....//x.pdf"]:
    print(repr(raw), "->", repr(safe_display_name(raw)))

print("落到存储上的键:", object_key(42, "../../etc/passwd"))

输出(最后一行的 uuid 每次不同):

'../../etc/passwd' -> 'passwd'
'C:\\Windows\\system32\\config\\sam' -> 'sam'
'..\\..\\.env' -> 'env'
'正常文档.pdf' -> '正常文档.pdf'
'a\x00.png' -> 'a.png'
'....//....//x.pdf' -> 'x.pdf'
落到存储上的键: u/42/30fa33ce277646a2a3dae5458cc0adb0

⭐⭐ 但这段代码真正想说的不是上面那个净化函数,是下面那个 object_key

⭐⭐ 正确的形状不是「把名字洗干净再当路径」,是「根本不拿用户的字符串做路径」。 存储键由你生成(u/{user_id}/{uuid}),用户那串字符只进数据库的一列,下载时放进 Content-Disposition。 这样路径穿越这个问题是不存在了,而不是被过滤掉了 —— 净化函数漏一种写法就出事,生成键漏不了。

⭐ 这和第 8 章那句「从结构上保证,不是靠记得写」是同一个思路。 ⚠️ 顺带说清:u/{user_id}/ 这个前缀不是隔离手段,它只让键天然不撞;谁能读谁的,仍然是第 8 章那套授权。

⚠️ 还有一条容易漏的别把用户上传的内容放在和你的应用同源的域名下。 如果对象存储的公开 URL 和你的站点同源,一个 HTML 文件传上去就是你这个源的一个页面 —— 08b那整套「浏览器在替谁防谁」的信任边界当场作废。 ⭐ 做法:用户内容放独立的域,或者只通过带 Content-Disposition: attachment 的签名 URL 下发。

③ ⭐⭐ 上传是那条注入链的入口

第 10 章第三节写了一条真实的攻击链:

用户 A 上传的文档里藏着 HTML → 它被切分、embedding、进了向量库 → 用户 B 提问时被检索出来进了上下文 → 模型照着输出 → B 的浏览器执行了它

⚠️ 那条链的第一个词就是「上传」,也就是这一章。 08c 第二节给它起了名字(AI 应用独有的存储型 XSS),并指出链上每一环都功能正常,没有一步是 bug

分工写清楚,免得你在这里找错东西

在哪 负责什么
本章 认清入口:你在第四节把这个文件送进队列的那一刻,就已经承认它是不可信输入
10 章第三节 ⭐⭐ 净化怎么做 —— 四条最小判据、五种正则绕过形态、「净化是进 innerHTML 前的最后一步」。本章一个字都不重复
08c 第二节 这类攻击的名字和谱系

本章唯一要多做的一件事是「记得住」uploads 表里留下谁传的、什么时候、对应哪个存储键。 ⚠️ 将来某一份文档被判定为投毒源时,你要答得出「它进了哪些人的上下文」—— 第 8 章WHERE user_id 挡的是「B 直接读到 A 的数据」,挡不住这条链; 能追溯,是你在这条链上唯一还剩的手段。


💰 七、存储成本与生命周期

⚠️ 这一节不写任何价格数字 —— 各家不同、随时在变(🗓️),写下来第二年就是错的。 ⭐ 只写相对关系,那部分不怎么变。

对象存储收三笔钱,很多人只算第一笔

收什么 说明
存了多少 按容量 × 时间
⭐⭐ 取出去多少(出网流量) 通常按每 GB 算下来比存储贵得多 —— 这是本节最该记住的一条相对关系
操作了多少次 每次上传、每次读、每次列目录都算一次。⚠️ 海量小文件会让这一笔变得意外地显眼

那条相对关系直接决定一个设计别把「每次回答都把原文件下发给前端」当成默认。 文本切片已经在你的库里(第 7 章),回答时引用切片就够;只有用户点「看原文」时才回源取一次。

存储分层(🗓️ 各家叫法不同):越冷越便宜,但取回越慢、还要付取回费。 ⚠️ 别把 RAG 要用的原文件放归档层 —— 那是给「三年前的合规备份」用的,不是给「用户随时可能点开」用的。

生命周期规则,至少配三条

  1. 清理未完成的分片上传(第五节那条,最容易漏)
  2. 清理 pending 超时留下的孤儿对象 —— 签了证但用户没传完,或者传了但没走到 /complete
  3. ⭐⭐ 用户删文档时的清理,下面单独说

⚠️⚠️ 删除:第 7 章那个 ON DELETE CASCADE 管不到对象存储

第 7 章第一节的核心论证是:向量和业务数据分开存,删除就是两步、不在一个事务里, 而 pgvector 把它变成一行 ON DELETE CASCADE

但对象存储永远在那个事务外面。 所以「删一份文档」在这里又变回了两步:

解法和顺序先删库记录(用户立刻就看不见了),再由后台任务异步删对象,失败就重试。 反过来做,用户会先看到一个坏掉的条目。

⚠️ 第 16 章那条验收项写的是「删业务记录时向量库里的分片也要没」—— ⭐ 有了这一章,它要多一句:对象存储里的那个文件也要没。


🔄 换个栈怎么对应

概念 Python / FastAPI(本板块主栈) Node(Express / Hono) Go
收 multipart(⚠️ 本章劝你别做) UploadFile / python-multipart multer / busboy r.MultipartReader()
签一张通行证 存储 SDK 的 presign 方法 同名方法 同名方法
自己算 HMAC hmac + hashlib crypto.createHmac crypto/hmac
读 magic bytes 读前几十字节自己比 一模一样 一模一样
体积上限(应用层 框架 / ASGI 服务器配置 中间件的 limit 选项 http.MaxBytesReader
⚠️ 体积上限(代理层 client_max_body_size 同一个,和语言无关 同一个,和语言无关

这张表要看出的是:只有最后两行是概念,上面四行都只是各家的叫法。 ⚠️ 而最后一行还提醒你一件更要紧的事:有一个上限根本不在你的代码里 —— 换什么语言都一样。


🔗 这一章连到哪里

去哪 为什么
⭐⭐ 07 · 向量检索落地 本章补的就是它第五节那张流程图的第一步。切分、embedding、pgvector 选型、索引全在那里,本章到「文件落地并入队」为止
⭐⭐ 11 · 长任务与队列 第四节的入队、幂等键(本章只讲这个场景的键怎么取,机制和可跑实现在那一章第四节)、以及「扫一遍卡住的行」那个兜底形状
⭐⭐ 10 · 把流式接到界面上 第三节:净化的四条最小判据、五种正则绕过形态。⚠️ 本章只负责指出「攻击链的入口在上传这里」,怎么防全在那一节
08c · CSRF 与 XSS 第二节给这条链起了名字(AI 应用独有的存储型 XSS),并说明链上每一环都「功能正常」
08 · 认证、会话与多租户 签证之前要先鉴权;⚠️ 存储键的 u/{user_id}/ 前缀不是隔离手段,授权仍然是那一章的事
14 · 容器化与部署 第五节那段 Nginx 配置里的 client_max_body_size,就是本章第二节说的两个 413 上限中的代理那一个
06 · 关系数据库 第一节那张清单里写着「上传的文件:文件在对象存储,元数据在库里」—— 本章是那一行的展开
13 · 配置与密钥 签名密钥属于「泄露一次就等于把写权限送人」的那一类,怎么存怎么轮换在那里
16 · 上线前检查单 那里已经有「超大 body 得 413」和「删业务记录时向量库分片也要没」两条;⭐ 本章第七节给后一条补上了「对象也要没」
17 · 实战与挑战项目 项目 ②「文档问答」的第一步就是本章;那里的验收项「上传后立刻返回」「同一文档传两次不存两份 embedding」在本章第四节兑现

✅ 检查点

  1. 让文件流过你的后端,会在哪四个地方出事?⭐ 那一句判据是什么?
  2. ⚠️ 413 至少有哪两个来源?⭐ 怎么一眼分辨是哪一层拦的?为什么说第三个位置反而是个论据?
  3. 预签名直传五步里,文件本体出现在第几步?⭐⭐ 为什么体积上限必须签进证里?那段代码第 ⑤ 行输出的是什么?
  4. 「后端怎么知道传完了」两条路各自不可靠在哪?⭐ 兜底是什么形状、和第 11 章的哪一条同构?
  5. 上传后那条链从 readyindexed 发生了什么?⭐ 幂等键由什么算出来、为什么要带模型名?
  6. 什么时候该上分片、什么时候不该?💀 不配生命周期规则会留下什么?
  7. ⭐ 为什么 Content-Type 和扩展名不能信?magic bytes 能确认到什么程度、不能确认什么?
  8. ⭐⭐ 处理用户文件名的正确形状是什么?为什么说「路径穿越是被消除了,不是被过滤了」?
  9. ⭐⭐ 上传和第 10 章第三节那条攻击链是什么关系?本章只负责哪一半?
  10. 对象存储收哪三笔钱、最该记住哪条相对关系?⚠️ 为什么第 7 章那个 ON DELETE CASCADE 管不到这里、删除的正确顺序是什么?
👀 答案
  1. 内存(框架常把请求体先读进内存,⚠️ 十个人同时传就是十份)、磁盘(临时文件写满 = 整个进程报磁盘满)、⭐ 请求时长(= 文件大小 ÷ 用户上行带宽,⚠️ 家用宽带上下行不对称,本地测 3 页 PDF 永远发现不了)、⭐⭐ worker 被占住(扩容维度当场错了:为了传文件要给整个 API 加机器)。⭐ 判据:耗时由「用户的网速」决定的请求,不该占着你的应用进程。
  2. 反向代理 / 入口网关(🗓️ client_max_body_size,默认 1 MB 量级)② 应用 / 框架自己的检查。⭐ 分辨方法:看应用日志里有没有这次请求 —— 代理拦的话请求根本没到你这儿。⚠️⚠️ 只调一个照样 413。第三个位置是托管平台的入口网关,你可能改不了 —— ⭐ 这正是「别让文件流过后端」的论据:走直传,三个上限一个都不用碰
  3. ⭐⭐ 只在第 ③ 步(浏览器 → 对象存储,不经过你)。上限必须签进证里,是因为浏览器里的判断按一次 F12 就没了,⚠️ 只在前端限制等于没有限制。第 ⑤ 行输出是 拒绝:签名对不上(策略被改过) —— 把上限从 10 MB 改成 999 MB 再拿原签名来用,直接被拒。⭐ 凡是要守住的约束,都必须由服务端签进那张证里。
  4. 客户端回调 /complete:⚠️ 客户端可能不调(关页面、断网)也可能骗你存储侧事件通知:要配置、有延迟、⚠️ 可能重复投递(天然需要幂等)。⭐ 兜底是 uploads 的状态机 + 一个后台任务扫「pending 超过 N 分钟」的行去 HEAD 一下(在 → ready,不在 → expired),⭐ 和第 11 章那条「把卡在 running 的 job 改回 queued」的清扫语句是同一个形状。⚠️ 另外服务端必须自己去存储核实一次,客户端报的大小和类型不可信。
  5. ready → 入队一个 kind='ingest' 的 job → worker 从对象存储拉回文件 → 切分 → 算 embedding → 写 chunks(第 7 章)→ indexed。⭐ 文件本体全程没进过 web 进程。幂等键由内容 hash + embedding 模型名算出(第 11 章的规矩是「键必须由输入决定」):内容 hash 让同一份 PDF 改名再传不再花一次钱;⭐ 带模型名是因为第 7 章讲过 换 embedding 模型 = 全部重算。⚠️ 这兑现了第 17 章项目 ② 那条验收项。
  6. 该上:⭐ 单文件几百 MB 以上(一次 PUT 失败 = 整个文件重传)、用户网络不稳、🗓️ 存储对单次上传有硬上限。⚠️ 几 MB 到几十 MB 的 PDF/图片不该上 —— 它带来三段式协议、每片的重试与并发管理,以及 💀 没走到「拼起来」的残片一直占钱、还在对象列表里看不见。⭐ 判据:重传一次的代价能不能接受,不能才上分片。
  7. 因为它们都是用户输入:⭐ 一个 Windows 可执行文件(头两字节 MZ)改名叫 setup.pdf 就同时骗过这两样。magic bytes 只能确认「头几个字节符合某种格式」;⚠️ 确认不到细粒度(data.docx 实读是 application/zip,docx/xlsx/pptx 本身就是 zip),⭐ 而且类型对不代表内容安全。判据是「白名单 + 头字节复核」,不是「查杀」。
  8. ⭐⭐ 根本不拿用户的字符串做路径:键由你生成(u/{user_id}/{uuid}),那串字符只进数据库一列,下载时放进 Content-Disposition。因为净化函数漏一种写法就出事,生成键漏不了 —— 问题被消除而不是被过滤,和第 8 章「从结构上保证,不是靠记得写」同一个思路。⚠️ 两条附注:u/{user_id}/ 前缀不是隔离手段用户上传内容别放在和应用同源的域名下,否则传个 HTML 上去就是你这个源的一个页面。
  9. ⭐⭐ 上传就是那条链的第一个词(A 传藏 HTML 的文档 → 进向量库 → B 检索到 → 模型照着输出 → B 的浏览器执行)。本章只负责认清入口(送进队列的那一刻就已承认它是不可信输入)和记得住uploads 表留下谁传的、哪个键,出事时答得出「它进了哪些人的上下文」)。⚠️ 净化怎么做全部归第 10 章第三节,本章一个字不重复;08d 第二节给这类攻击命名。
  10. 存了多少、⭐⭐ 取出去多少(出网流量)操作了多少次(⚠️ 海量小文件让这笔意外地显眼)。⭐ 相对关系:出网流量按每 GB 算通常比存储贵得多别把「每次回答都下发原文件」当默认。⭐ CASCADE 管不到是因为对象存储永远在那个事务外面,删除又变回两步:删库成功删对象失败 = 孤儿文件一直占钱,反过来 = 💀 列表里还有、点开 404(更糟)。正确顺序是先删库记录、再异步删对象并重试;第 16 章那条验收项要补成「对象也要没」。

🛑 可以停在这里

走神救援

⭐⭐ 这一章补的是第 7 章第五节那张流程图的第一个箭头 —— 那里写着「上传文档 → 切分 → 算 embedding → 入库」,而「上传」两个字在全章只出现过这一次。核心立场:文件不该流过你的后端。 让它流过会在四处一起出事:内存(框架常先把请求体读进内存,十个人同时传就是十份)、磁盘(临时文件写满 = 整个进程报磁盘满)、⭐ 请求时长(= 文件大小 ÷ 用户上行带宽,⚠️ 家用宽带上下行不对称,本地测那个 3 页 PDF 永远发现不了)、⭐⭐ worker 被占住(为了让人传文件要给整个 API 加机器,扩容维度当场错了)。⭐ 判据:耗时由「用户网速」决定的请求,不该占着你的应用进程。 ⚠️⚠️ 413 至少有两个来源:🗓️ 代理层的 client_max_body_size(默认 1 MB 量级,14 章第五节那段配置里就有)和应用层自己的检查,症状一模一样、只调一个照样 413;⭐ 分辨方法是看应用日志里有没有这次请求 —— 没有就是代理拦的。💀 还有第三个位置:托管平台的入口网关,你可能改不了;⭐ 这恰恰是走直传的论据 —— 直传把这三个上限全绕开了。⭐⭐ 预签名直传五步:① 浏览器报文件名大小类型 → ② 后端鉴权、查配额、自己生成存储键、签一张限时限权的证、插一行 pending → ③ ⭐⭐ 浏览器把文件本体直接传给对象存储,不经过你 → ④ 浏览器回调 /complete → ⑤ 后端去存储核实再入队。三条好处正好对应上面三个毛病:不碰字节流、扩容和文件大小彻底解耦、而授权仍在你手里。⭐⭐ 限制必须签进证里:那段 hmac 代码跑出五行,超大 / 类型不符 / 过期都被拒,第 ⑤ 行把上限从 10 MB 改成 999 MB 再拿原签名来用 —— 拒绝:签名对不上(策略被改过)。⚠️ 只在前端 JS 判断「超过 10 MB 不让选」等于没有上限。 证还要短命一张只对一个键有效传完之后你其实不知道:客户端回调可能不调也可能骗你,存储事件通知有延迟且可能重复投递(天然需要11 章第四节的幂等);⭐ 所以要配兜底 —— 后台扫「pending 超过 N 分钟」的行去 HEAD 一下,和 11 章那条「把卡在 running 的 job 放回去」是同一个形状。核实通过就入队,链条补完整:ready → ingest job → worker 拉回文件 → 切分 → embedding → chunks → indexed,⭐ 文件本体从头到尾没进过 web 进程。幂等键取 内容 hash + embedding 模型名(键必须由输入决定;带模型名是因为第 7 章说换模型要全部重算),这兑现了17 章那条「同一文档传两次不存两份 embedding」。分片只在三种信号下才值得:单文件几百 MB 以上、用户网络不稳、存储对单次 PUT 有硬上限;⚠️ 几十 MB 的 PDF 别上 —— 💀 没走到「拼起来」的残片一直占钱、还在对象列表里看不见。⭐ 判据:重传一次的代价能不能接受,不能才上分片。 安全三条:① Content-Type 和扩展名都是用户输入,要读 magic bytesMZ 开头的可执行文件改名 setup.pdf 当场露馅),⚠️ 但它只能确认「是个 zip」这种粗粒度,类型对不代表内容安全;② ⭐⭐ 用户的文件名根本不该当路径 —— 键由你生成 u/{user_id}/{uuid},那串字符只进数据库一列,路径穿越是被消除了而不是被过滤了,⚠️ 且用户内容别放在和应用同源的域下;③ ⭐⭐ 上传是10 章第三节那条注入链的入口(A 传藏 HTML 的文档 → B 检索到 → 模型照着输出 → B 的浏览器执行),本章只负责认清入口和留下可追溯的记录,净化四条判据全归那一节成本收三笔:存了多少、⭐⭐ 取出去多少(出网流量按 GB 通常比存储贵得多)、操作了多少次;⭐ 所以别把「每次回答都下发原文件」当默认。⚠️⚠️ 最后一条最容易漏:第 7 章那个 ON DELETE CASCADE 管不到对象存储 —— 它在事务外面,删除又变回不在一个事务里的两步,⭐ 正确顺序是先删库记录(用户立刻看不见)、再异步删对象并重试16 章那条验收项要从「向量分片也要没」补成「对象也要没」。

下一节 👉 08-认证会话与多租户.md

打卡记录保存在你的浏览器里,首页能看到总进度