
前几章把数据库、认证鉴权、API 基础设施讲清楚了,这章聊聊文件存储这个子系统。
AI(人工智能)平台要处理的用户数据种类不少:
图片
视频
音频
文档
PDF 文件
文本文件
生成式媒体
处理后的输出
临时处理产物
这类二进制数据直接塞进 PostgreSQL 是不行的。
正确的做法是分离:
元数据 ↓
PostgreSQL 大文件二进制数据 ↓
对象存储
最终架构是这样:
ACAI │ ┌──────┴──────┐ │ │ PostgreSQL 对象存储 │ │ 元数据 二进制数据
分离之后,扩展性、性能、生命周期管理、安全性都能上一个台阶。
PostgreSQL 适合存结构化信息:
fileId
ownerId
projectId
fileName
mimeType
size
status
storageKey
createdAt
对象存储适合存:
photo.jpg
video.mp4
audio.wav
document.pdf
generated-image.png
数据库在这里扮演"权威元数据层"的角色。
基本流程是:
用户 │ ▼
上传请求 │ ▼
身份认证 │ ▼
权限校验 │ ▼
上传策略 │ ▼
对象存储 │ ▼
元数据记录 │ ▼
处理管道
大文件场景下,可以用直传或者分块上传模式:
客户端 │ ▼
API 请求上传授权 │ ▼
服务端校验请求 │ ▼
临时上传授权 │ ▼
对象存储 │ ▼
上传完成 │ ▼
服务端记录/更新元数据
客户端绝对不能拿到无限制的存储凭证。
文件应该有内部存储 key,而不是直接依赖用户上传时的原始文件名。
比如:
users/{userId}/projects/{projectId}/files/{fileId}/original
生成的资产可以用:
users/{userId}/projects/{projectId}/generations/{generationId}/output
具体格式可以灵活调整,但 key 应该满足:
假设用户上传了:
我的度假照片.jpg
应用存的是:
name = "我的度假照片.jpg"
但内部用的 key 是:
users/<用户>/projects/<项目>/files/<文件>/original
这样分离之后,用户文件名就不会成为存储对象的主要标识了。
之前提到的 File 模型可以扩展成这样:
model File { id String @id @default(uuid()) name String mimeType String sizeBytes BigInt status FileStatus @default(UPLOADING) ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) projectId String? project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) storageKey String? checksum String? metadata Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([ownerId]) @@index([projectId]) @@index([status])
}
可能需要存的元数据包括:
宽度
高度
时长
页数
编码格式
处理状态
只存真正有用的元数据,别什么都往里塞。
文件应该有明确的状态流转:
UPLOADING ↓
UPLOADED ↓
PROCESSING ↓
READY
失败路径:
PROCESSING ↓
FAILED
删除:
READY ↓
DELETED
这样设计,应用能区分两种情况:
文件存在但还在处理中
和:
文件处理失败了
安全的上传系统至少要校验这些:
文件大小
声明的 MIME 类型
实际文件特征
扩展名
应用支持的格式
所有权
目标项目
上传授权
客户端提供的文件名或者 MIME 类型绝对不能直接当权威数据用。
不同资源类型可以有不同的限制:
头像 ↓
小文件限制 文档 ↓
中等文件限制 视频 ↓
大文件限制
具体数值应该根据产品需求、基础设施容量、压测结果来定。
限制必须由服务端强制执行,不能只靠前端。
浏览器可能上报:
image/jpeg
但服务端不能盲目信任这个声明。
在合适的情况下,处理管道应该检查文件的实际特征。
原则是:
声明的元数据 +
内容检测 +
应用策略
用户可控的文件名里可能包含奇怪的或者误导性的扩展名。
应用不能直接用不可信的文件名来构造敏感的存储行为。
正确做法:
原始文件名 ↓
存为元数据 ↓
内部对象 key
存储 key 必须由应用生成。
每个私有文件都必须有明确的归属边界。
概念上:
文件 │ ├── ownerId └── projectId
访问前:
当前用户 ↓
项目/文件 ↓
所有权或成员身份 ↓
权限判断
只有权限校验通过之后,应用才提供访问。
用户的私有文件通常不应该通过匿名公开 URL 访问。
推荐模式:
用户 │ ▼
认证 API │ ▼
权限校验 │ ▼
临时授权访问 │ ▼
对象存储
这样设计,拿到一个 URL 也不会自动获得永久访问权。
对于私有对象,如果存储平台支持,应用可以下发短命的授权凭证。
概念上:
请求 ↓
身份认证 ↓
权限校验 ↓
生成临时访问凭证 ↓
客户端获取对象
有效期要控制在实际使用场景所需的范围内。
一个可靠的上传序列:
1. 用户选择文件 ↓
2. 客户端请求上传授权 ↓
3. 服务端认证用户 ↓
4. 服务端校验项目所有权 ↓
5. 服务端校验请求的文件策略 ↓
6. 服务端创建文件元数据 ↓
7. 下发上传授权 ↓
8. 客户端上传对象 ↓
9. 记录上传完成 ↓
10. 创建处理任务
这样形成一条可追溯的生命周期。
上传之后:
对象存储 ↓
处理队列 ↓
Worker ↓
校验 ↓
提取 ↓
转换 ↓
结果 ↓
数据库
文档的处理流程:
PDF ↓
文本提取 ↓
规范化 ↓
分块 ↓
向量化 ↓
向量索引
图片的处理流程:
图片 ↓
元数据提取 ↓
校验 ↓
可选处理 ↓
生成/处理后的资产
文件处理基础设施应该假设所有上传文件都是不可信的。
处理环境要和特权基础设施尽可能隔离。
核心原则:
不可信文件 ↓
受控处理环境 ↓
最小权限 ↓
不必要的网络访问 ↓
校验后的输出
具体安全控制取决于部署架构。
文件处理 Worker 不应该默认拥有这些权限:
生产库凭证
认证密钥
支付凭证
管理 API
其他用户的文件
Worker 只能拿到任务所需的权限。
这叫最小权限原则。
处理过程中可能需要本地临时存储。
比如:
对象存储 ↓
Worker 临时目录 ↓
处理 ↓
输出存储 ↓
清理临时数据
临时文件不能无限期留存。
生命周期策略要定义清楚:
创建时间
存储位置
访问权限
清理时间
AI 生成的输出应该作为一等公民来对待。
比如:
生成记录 │ ├── 请求元数据 ├── 模型信息 ├── 状态 └── 输出文件
一次生成可能产出:
图片
视频
音频
文档
输出应该和生成记录有明确的关联关系。
编辑系统可能需要版本管理。
比如:
原始版本 ↓
编辑版本 1 ↓
编辑版本 2 ↓
编辑版本 3
可以用这样的模型:
FileVersion ├── id ├── fileId ├── versionNumber ├── storageKey ├── createdAt └── metadata
这对创意类应用特别有用,用户期望有撤销、版本历史、恢复功能。
一个源文件可能生成多个衍生版本。
比如:
原始图片 │ ├── 缩略图 ├── 预览图 ├── 优化版本 └── 编辑版本
数据库要维护源资产和衍生资产的关联关系。
这样应用才不会丢失对输出来源的追踪。
校验和可以帮助检测意外损坏或者识别相同内容。
概念上:
文件 ↓
哈希函数 ↓
校验和
校验和可以存在元数据里。
但校验和不能被当成万能的安全机制来用。
它的具体作用要定义清楚。
用户可以有存储上限。
概念上:
用户 │ ├── 已用存储 ├── 存储上限 └── 可用存储
接受上传之前:
请求大小 ↓
当前用量 ↓
配额检查 ↓
允许 / 拒绝
配额计算必须在服务端执行。
同样原则可以应用到项目级别。
账户配额 │ ├── 项目 A ├── 项目 B └── 项目 C
项目可以有自己独立的资源限制。
这在协作或组织环境下很有用。
删除文件应该是受控的工作流。
删除请求 ↓
身份认证 ↓
权限校验 ↓
标记已删除 ↓
清理存储 ↓
审计事件
对于重要数据,延迟删除策略可能更合适:
ACTIVE ↓
MARKED_FOR_DELETION ↓
保留期 ↓
永久删除
这样可以提供恢复窗口。
分布式系统可能产生数据不一致。
比如:
数据库记录存在
但对象缺失
或者:
对象存在
但数据库记录未完成
对账流程可以定期检测这类情况。
概念上:
数据库 ↕
对象存储 ↕
对账 Worker
可能的状态:
CONSISTENT
MISSING_OBJECT
MISSING_METADATA
PROCESSING_STUCK
对象存储应该在合适的地方用生命周期规则。
比如临时上传:
临时上传 ↓
超过保留期后删除
处理产物:
临时产物 ↓
自动清理
旧版本:
不活跃版本 ↓
长期保留或删除
生命周期策略能减少不必要的存储成本和运维负担。
下载端点不能直接接受:
/api/files/{id}/download
然后返回对象。
正确做法:
请求 ↓
身份认证 ↓
查找文件 ↓
检查所有者/成员身份 ↓
检查权限 ↓
生成授权访问 ↓
返回
这保持了和第 38 章相同的授权原则。
一种可能的 API 结构:
POST /api/files
GET /api/files
GET /api/files/{id}
DELETE /api/files/{id}
POST /api/files/{id}/complete
POST /api/files/{id}/process
大文件上传场景,API 可以把:
创建上传
完成上传
处理文件
和普通文件元数据操作分开。
每个私有文件端点都要考虑:
身份认证
授权
所有权
项目成员身份
文件状态
配额
限流
输入校验
审计日志
这点特别重要,因为文件 ID 通常容易被客户端获取。
知道一个 ID 不应该自动获得访问权。
当文档被 AI 使用时,文件架构尤其重要。
安全管道是:
上传 ↓
校验 ↓
存储 ↓
文本提取 ↓
分块 ↓
向量化 ↓
向量存储 ↓
授权检索 ↓
AI 上下文
检索阶段必须遵守和原始文档相同的所有权和权限边界。
假设:
项目 A ├── 文档 1 └── 文档 2 项目 B └── 文档 3
如果用户只能访问项目 A,AI 检索查询绝对不能意外返回文档 3。
所以:
语义相关性 +
授权过滤器
两个条件必须同时满足。
相关的文档不一定是已授权的文档。
正确的模型是:
候选文档 ↓
授权过滤器 ↓
符合条件的文档 ↓
相关性排序 ↓
AI 上下文
另一种实现可以在排序之前先应用授权。
关键要求是:未授权文档绝对不能进入模型上下文。
视频和音频可以异步处理。
比如:
上传 ↓
元数据提取 ↓
队列 ↓
Worker ↓
转码 / 分析 ↓
输出
前端可以显示:
上传中
处理中
已完成
失败
而不是让用户等待一个漫长的 HTTP 请求。
处理任务可以暴露:
0%
25%
50%
75%
100%
但进度只有在能有效估算时才应该上报。
不应该为了界面看起来活跃而虚假精确。
文件可能包含高度敏感信息。
所以:
访问控制
加密
保留
删除
审计
应该一起考虑。
系统应该只收集和保留功能所需的数据。
对象存储和数据库备份应该分开考虑。
PostgreSQL 备份 +
对象存储保护
只有数据库备份没有对应对象可能不完整。
同样,只有对象没有元数据也可能难以解析。
恢复计划要定义清楚两层如何一起恢复。
恢复工作流可能是:
事件 ↓
基础设施恢复 ↓
数据库恢复 ↓
对象存储验证 ↓
元数据/对象对账 ↓
应用恢复 ↓
完整性验证
恢复流程要测试,不能只是写文档。
[ ] 文件被视为不可信输入
[ ] 文件所有权被记录
[ ] 项目访问被校验
[ ] 大小限制被执行
[ ] 文件类型被校验
[ ] 用户文件名不作为对象标识
[ ] 存储 key 在服务端生成
[ ] 私有对象不能匿名访问
[ ] 临时访问有时限
[ ] 处理 Worker 使用最小权限
[ ] 临时文件被清理
[ ] 删除对象遵循定义的生命周期
[ ] 能检测孤儿对象
[ ] 存储配额被执行
[ ] 文件操作可审计
[ ] 数据库和存储恢复协同
完整设计:
用户 │ ▼ 身份认证 │ ▼ 权限校验 │ ▼ 文件 API │ ┌───────────┴───────────┐ │ │ 文件元数据 上传策略 │ │ ▼ ▼ PostgreSQL 对象存储 │ │ └───────────┬───────────┘ │ ▼ 处理队列 │ ▼ Worker │ ┌────────────┴────────────┐ │ │ 文档管道 媒体管道 │ │ ▼ ▼ 文本/向量 衍生资产 │ │ └────────────┬────────────┘ │ ▼ AI 服务
文件系统不只是个存放上传文件的地方。
它是安全和数据治理子系统。
它决定了:
谁拥有数据?
谁能访问?
保留多久?
如何处理?
存在哪里?
删除后怎样?
能恢复吗?
AI 系统能检索吗?
处理 Worker 能访问吗?
这些问题必须在平台处理大量用户数据之前回答清楚。
第 40 章建立了对象存储架构。
系统现在有清晰的分离:
结构化元数据 ↓
PostgreSQL 大文件二进制对象 ↓
对象存储
以及受控的生命周期:
上传 ↓
校验 ↓
存储 ↓
处理 ↓
授权 ↓
检索 ↓
保留 ↓
删除
最重要的原则是:
文件被存储了不等于它就可以被访问。
每次访问都必须通过身份、授权、所有权和策略边界。
这个架构为下一个主要子系统做好了准备:文档摄入、文本提取、分块、向量化、向量存储、检索和 RAG(检索增强生成)。
第 40 章完