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